feat: Telegram V2 — inbound commands + actionable approve/reject from chat (#551)

* feat(telegram): V2 inbound — command router, actionable approve/reject keyboards, chat-gated poll loop

* fix(release,x,video,telegram): terminal-state guards on approve/reject; sender-identity check

* docs(map,rag): Telegram V2 inbound surfaces and terminal-state approve/reject guards

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:47:09 +02:00
committed by GitHub
co-authored by Renn F
parent c9c1f62981
commit 9fbec78126
30 changed files with 2732 additions and 68 deletions
@@ -284,7 +284,14 @@ async def test_notify_telegram_send_deferred_to_after_commit(
sent: list[str] = []
class _FakeTelegramClient:
async def send_message(self, text: str) -> TelegramSendResult:
async def send_message(
self,
text: str,
*,
reply_markup: dict | None = None,
reply_to_message_id: int | None = None,
) -> TelegramSendResult:
_ = (reply_markup, reply_to_message_id)
sent.append(text)
return TelegramSendResult(sent=True)
@@ -331,7 +338,14 @@ async def test_notify_telegram_rollback_drops_send(
sent: list[str] = []
class _FakeTelegramClient:
async def send_message(self, text: str) -> TelegramSendResult:
async def send_message(
self,
text: str,
*,
reply_markup: dict | None = None,
reply_to_message_id: int | None = None,
) -> TelegramSendResult:
_ = (reply_markup, reply_to_message_id)
sent.append(text)
return TelegramSendResult(sent=True)
@@ -0,0 +1,67 @@
"""Telegram inbound (V2) is gated by default-off config flags (mirrors the
x_feature_spotlight sub-switch pattern)."""
from __future__ import annotations
import os
from unittest import mock
import pytest
from roboco.config import Settings
from roboco.services.settings import (
FEATURE_FLAGS,
SettingValidationError,
validate_setting,
)
_DEFAULT_POLL_INTERVAL_SECONDS = 5.0
_DEFAULT_POLL_TIMEOUT_SECONDS = 25
_DEFAULT_MAX_UPDATES_PER_CYCLE = 50
_OVERRIDE_POLL_INTERVAL_SECONDS = 10.0
def test_telegram_inbound_disabled_by_default() -> None:
s = Settings()
assert s.telegram_inbound_enabled is False
assert s.telegram_poll_interval_seconds == _DEFAULT_POLL_INTERVAL_SECONDS
assert s.telegram_poll_timeout_seconds == _DEFAULT_POLL_TIMEOUT_SECONDS
assert s.telegram_max_updates_per_cycle == _DEFAULT_MAX_UPDATES_PER_CYCLE
def test_telegram_inbound_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_TELEGRAM_INBOUND_ENABLED": "true"}):
assert Settings().telegram_inbound_enabled is True
def test_telegram_poll_interval_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_TELEGRAM_POLL_INTERVAL_SECONDS": "10"}):
assert (
Settings().telegram_poll_interval_seconds == _OVERRIDE_POLL_INTERVAL_SECONDS
)
def test_telegram_inbound_flag_registered_in_feature_flags() -> None:
assert "telegram_inbound_enabled" in [key for key, _ in FEATURE_FLAGS]
def test_telegram_inbound_flag_validates_as_bool() -> None:
validate_setting("telegram_inbound_enabled", "true")
def test_telegram_last_update_id_validates_as_int() -> None:
validate_setting("telegram_last_update_id", "12345")
def test_telegram_last_update_id_rejects_non_int() -> None:
with pytest.raises(SettingValidationError):
validate_setting("telegram_last_update_id", "not-a-number")
def test_telegram_last_update_id_rejects_negative() -> None:
with pytest.raises(SettingValidationError):
validate_setting("telegram_last_update_id", "-1")
def test_telegram_last_update_id_not_a_feature_flag() -> None:
"""It's an internal cursor, not a panel-tunable master switch."""
assert "telegram_last_update_id" not in [key for key, _ in FEATURE_FLAGS]
@@ -0,0 +1,51 @@
"""The Telegram inbound orchestrator loop is fully dormant when disabled
(default) — mirrors ``test_x_engine_loop_dormant.py``.
With either ``telegram_enabled`` or ``telegram_inbound_enabled`` off,
``_telegram_poll_loop`` must return immediately — no sleep, no HTTP, no DB —
so a standard deployment (and even a V1-only deployment with just
``telegram_enabled`` on) behaves exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_telegram_poll_loop_returns_immediately_when_both_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "telegram_enabled", False)
monkeypatch.setattr(cfg, "telegram_inbound_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_telegram_poll_loop_dormant_when_only_v1_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""V1-only deployment (notifications, no inbound): the poll loop still
never runs."""
monkeypatch.setattr(cfg, "telegram_enabled", True)
monkeypatch.setattr(cfg, "telegram_inbound_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_telegram_poll_loop_dormant_when_only_inbound_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The sub-switch alone (V1 master switch off) is still fully inert."""
monkeypatch.setattr(cfg, "telegram_enabled", False)
monkeypatch.setattr(cfg, "telegram_inbound_enabled", True)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._telegram_poll_loop(stub), timeout=1.0)
@@ -0,0 +1,172 @@
"""``approve()``/``reject()`` terminal-status guards.
The confirmed HIGH-severity bug: ``reject()`` sets a proposal CANCELLED, but
(pre-fix) ``approve()`` checked nothing — a stale Approve (e.g. a Telegram
button clicked after a reject) re-ran the fail-closed executor over a
rejected proposal. This mirrors the analogous DB-backed regression tests in
``test_x_post_service.py`` / ``test_video_post_service.py``, patching only
the executor seam (``get_release_executor``) exactly like the other
release-proposal hook test files (``test_release_proposal_x_hook.py`` et al.)
do for their own seams.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType
from roboco.models.base import Team as T
from roboco.services.release_proposal import (
ReleaseProposalService,
TaskAlreadyCompletedError,
)
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
from roboco.services.task import RELEASE_MANAGER_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
_VERSION = "0.18.0"
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing"],
drafted_changelog=f"## [{_VERSION}]\n\n### Added\n- a thing\n",
version_bump_plan=["pyproject.toml"],
gaps=[],
migration_notes=[],
gate_state="green",
)
async def _seed_proposal(session: AsyncSession) -> TaskTable:
system_uuid = _foundation.AGENTS["system"].uuid
secretary_uuid = _foundation.AGENTS["secretary-1"].uuid
for uuid_, slug, role in (
(system_uuid, "system", AgentRole.SYSTEM),
(secretary_uuid, "secretary-1", AgentRole.SECRETARY),
):
if await session.get(AgentTable, uuid_) is None:
session.add(
AgentTable(
id=uuid_,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=T.BACKEND,
created_by=system_uuid,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title=f"Release proposal: v{_VERSION}",
description="proposal body",
acceptance_criteria=["CEO approves"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
project_id=project.id,
created_by=system_uuid,
assigned_to=secretary_uuid,
team=T.MAIN_PM,
source=RELEASE_MANAGER_SOURCE,
confirmed_by_human=False,
orchestration_markers={"release_report": report_to_dict(_report())},
)
session.add(task)
await session.flush()
return task
@pytest.mark.asyncio
async def test_approve_refuses_already_rejected_proposal(
db_session: AsyncSession,
) -> None:
"""The chokepoint guard: approving a CANCELLED (already-rejected)
proposal refuses and never invokes the executor — the reproduced bug."""
task = await _seed_proposal(db_session)
task.status = TaskStatus.CANCELLED
await db_session.flush()
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock()
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
assert result is not None
assert result.status == "already_rejected"
fake_executor.execute.assert_not_awaited()
await db_session.refresh(task)
assert task.status == TaskStatus.CANCELLED # untouched, never re-run
@pytest.mark.asyncio
async def test_approve_refuses_already_published_proposal(
db_session: AsyncSession,
) -> None:
"""The residual: a stale Approve on an already-COMPLETED (published)
proposal must refuse without re-entering the executor (which could
re-fire the post-publish draft hooks)."""
task = await _seed_proposal(db_session)
task.status = TaskStatus.COMPLETED
await db_session.flush()
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock()
with patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
):
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
assert result is not None
assert result.status == "already_published"
fake_executor.execute.assert_not_awaited()
await db_session.refresh(task)
assert task.status == TaskStatus.COMPLETED
@pytest.mark.asyncio
async def test_reject_raises_when_already_published(db_session: AsyncSession) -> None:
"""The mirror hole: rejecting an already-COMPLETED (published) proposal
must refuse — cancelling it would lie about an already-public release."""
task = await _seed_proposal(db_session)
task.status = TaskStatus.COMPLETED
await db_session.flush()
with pytest.raises(TaskAlreadyCompletedError):
await ReleaseProposalService(db_session).reject(
cast("UUID", task.id), "needs another migration check"
)
await db_session.refresh(task)
assert task.status == TaskStatus.COMPLETED # untouched, never cancelled
@@ -64,3 +64,101 @@ async def test_live_client_send_message_http_error_is_graceful() -> None:
assert result.sent is False
assert "401" in result.detail
await client.close()
_REPLY_TO_MESSAGE_ID = 42
_SENT_MESSAGE_ID = 7
@pytest.mark.asyncio
async def test_live_client_send_message_with_reply_markup_and_message_id() -> None:
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content.decode())
assert body["reply_markup"] == {"force_reply": True}
assert body["reply_to_message_id"] == _REPLY_TO_MESSAGE_ID
return httpx.Response(
200, json={"ok": True, "result": {"message_id": _SENT_MESSAGE_ID}}
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
result = await client.send_message(
"hi",
reply_markup={"force_reply": True},
reply_to_message_id=_REPLY_TO_MESSAGE_ID,
)
assert result.sent is True
assert result.message_id == _SENT_MESSAGE_ID
await client.close()
@pytest.mark.asyncio
async def test_live_client_get_updates_success() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/bot123456:ABC/getUpdates"
assert request.url.params["timeout"] == "25"
assert request.url.params["offset"] == "10"
return httpx.Response(
200, json={"ok": True, "result": [{"update_id": 10}, {"update_id": 11}]}
)
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
updates = await client.get_updates(offset=10, timeout=25, limit=50)
assert [u["update_id"] for u in updates] == [10, 11]
await client.close()
@pytest.mark.asyncio
async def test_live_client_get_updates_network_error_returns_empty() -> None:
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
updates = await client.get_updates(offset=None, timeout=25, limit=50)
assert updates == []
await client.close()
@pytest.mark.asyncio
async def test_live_client_answer_callback_query_posts_id() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/bot123456:ABC/answerCallbackQuery"
body = json.loads(request.content.decode())
assert body == {"callback_query_id": "cq1", "text": "ok"}
return httpx.Response(200, json={"ok": True, "result": True})
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
await client.answer_callback_query("cq1", "ok")
await client.close()
@pytest.mark.asyncio
async def test_live_client_edit_message_reply_markup_clears_keyboard() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/bot123456:ABC/editMessageReplyMarkup"
body = json.loads(request.content.decode())
assert body["reply_markup"] == {}
return httpx.Response(200, json={"ok": True, "result": True})
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
await client.edit_message_reply_markup(7, None)
await client.close()
@pytest.mark.asyncio
async def test_null_client_v2_methods_are_all_noops() -> None:
client: NullTelegramClient = NullTelegramClient()
assert await client.get_updates(offset=None, timeout=25, limit=50) == []
# None of these raise — that's the whole contract.
await client.answer_callback_query("cq1", "text")
await client.edit_message_reply_markup(1, None)
await client.edit_message_text(1, "done")
@@ -0,0 +1,745 @@
"""TelegramInboundEngine coverage: chat-id rejection, offset advancement, and
one test per approve/reject-kind dispatching to a mocked service (asserting
CEO identity + reason threading). Every service factory the engine calls is
monkeypatched module-level — no DB, no network."""
from __future__ import annotations
import time
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.services import telegram_inbound as ti
from roboco.services.roadmap_service import RoadmapItemResult
from roboco.services.telegram_credentials import TelegramCredentialsData
from roboco.services.video_post_service import VideoPostExecuteResult
from roboco.services.x_post_service import XPostExecuteResult
CEO_UUID = ti._CEO_UUID
def _uuid_with_prefix(prefix: str) -> UUID:
"""A real UUID whose ``str(uuid)[:8] == prefix`` — the id8 convention."""
return UUID(hex=prefix + uuid4().hex[len(prefix) :])
def _fake_task(id8: str = "a1b2c3d4", title: str = "Test task") -> SimpleNamespace:
return SimpleNamespace(
id=_uuid_with_prefix(id8),
title=title,
description="",
pr_url=None,
status=SimpleNamespace(value="pending"),
team=None,
)
def _fake_session() -> MagicMock:
"""``session.add`` is sync in real SQLAlchemy (a plain MagicMock call, no
"never awaited" warning); only the awaited methods this engine actually
uses get an AsyncMock."""
session = MagicMock()
session.commit = AsyncMock()
session.execute = AsyncMock()
return session
def _engine() -> ti.TelegramInboundEngine:
"""A bare engine over a mocked session — nothing in these tests touches
real DB rows, only the monkeypatched service factories."""
return ti.TelegramInboundEngine(_fake_session())
CREDS = TelegramCredentialsData(bot_token="123:ABC", chat_id="777")
# ---------------------------------------------------------------------------
# chat-id rejection
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_unauthorized_chat_message_is_dropped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
client = AsyncMock()
await engine._handle_message(
{"chat": {"id": 999}, "text": "/status"}, CREDS, client
)
dispatch.assert_not_called()
client.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_authorized_chat_message_dispatches_command(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
client = AsyncMock()
await engine._handle_message(
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
)
dispatch.assert_awaited_once_with("status", "", client)
@pytest.mark.asyncio
async def test_unauthorized_chat_callback_answers_not_authorized() -> None:
engine = _engine()
client = AsyncMock()
await engine._handle_callback(
{
"id": "cq1",
"data": "apv:xpost:a1b2c3d4",
"message": {"chat": {"id": 999}, "message_id": 5},
},
CREDS,
client,
)
client.answer_callback_query.assert_awaited_once_with("cq1", "Not authorized")
client.send_message.assert_not_called()
# ---------------------------------------------------------------------------
# sender identity — defense-in-depth on top of chat-id authorization
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_message_with_mismatched_sender_is_dropped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The right chat, but a `from.id` that disagrees with it (would only
happen if the "private" chat somehow carried a second poster) — dropped
silently, same as an unauthorized chat."""
engine = _engine()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
client = AsyncMock()
await engine._handle_message(
{"chat": {"id": 777}, "from": {"id": 999}, "text": "/status"}, CREDS, client
)
dispatch.assert_not_called()
client.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_message_with_matching_sender_dispatches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
client = AsyncMock()
await engine._handle_message(
{"chat": {"id": 777}, "from": {"id": 777}, "text": "/status"}, CREDS, client
)
dispatch.assert_awaited_once_with("status", "", client)
@pytest.mark.asyncio
async def test_message_without_from_keeps_prior_behavior(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No `from` on the update at all — the pre-Fix-2 behavior (chat-id-only)
is unchanged."""
engine = _engine()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
client = AsyncMock()
await engine._handle_message(
{"chat": {"id": 777}, "text": "/status"}, CREDS, client
)
dispatch.assert_awaited_once_with("status", "", client)
@pytest.mark.asyncio
async def test_callback_with_mismatched_sender_answers_not_authorized() -> None:
engine = _engine()
client = AsyncMock()
await engine._handle_callback(
{
"id": "cq1",
"data": "apv:xpost:a1b2c3d4",
"from": {"id": 999},
"message": {"chat": {"id": 777}, "message_id": 5},
},
CREDS,
client,
)
client.answer_callback_query.assert_awaited_once_with("cq1", "Not authorized")
client.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_callback_with_matching_sender_proceeds(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
client = AsyncMock()
dispatch = AsyncMock(return_value=(True, "ok"))
monkeypatch.setattr(engine, "_dispatch_approve", dispatch)
await engine._handle_callback(
{
"id": "cq1",
"data": "apv:xpost:a1b2c3d4",
"from": {"id": 777},
"message": {"chat": {"id": 777}, "message_id": 5},
},
CREDS,
client,
)
dispatch.assert_awaited_once()
client.answer_callback_query.assert_any_await("cq1", "Working...")
# ---------------------------------------------------------------------------
# expired force-reply prompt
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_expired_reply_prompt_sends_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A popped-but-expired pending prompt must tell the CEO instead of
silently doing nothing (the CEO otherwise has no idea why their reply had
no effect)."""
engine = _engine()
client = AsyncMock()
dispatch = AsyncMock()
monkeypatch.setattr(engine, "_dispatch_command", dispatch)
ti._PENDING_REPLIES[("777", 42)] = ti._PendingAction(
kind="xpost",
id8="a1b2c3d4",
extra="",
action="reject",
origin_message_id=10,
expires_at=time.monotonic() - 1, # already expired
)
await engine._handle_message(
{
"chat": {"id": 777},
"text": "some reason",
"reply_to_message": {"message_id": 42},
},
CREDS,
client,
)
client.send_message.assert_awaited_once_with(
"That prompt expired — tap the button again."
)
dispatch.assert_not_called()
assert ("777", 42) not in ti._PENDING_REPLIES
# ---------------------------------------------------------------------------
# offset advancement
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_cycle_noop_when_flags_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ti.settings, "telegram_enabled", False)
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
engine = _engine()
creds_svc = AsyncMock()
monkeypatch.setattr(
ti, "get_telegram_credentials_service", lambda _session: creds_svc
)
await engine.run_cycle()
creds_svc.get_decrypted.assert_not_called()
@pytest.mark.asyncio
async def test_run_cycle_noop_without_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
engine = _engine()
creds_svc = AsyncMock()
creds_svc.get_decrypted = AsyncMock(return_value=None)
monkeypatch.setattr(
ti, "get_telegram_credentials_service", lambda _session: creds_svc
)
await engine.run_cycle()
creds_svc.get_decrypted.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_cycle_advances_offset_past_highest_update_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
engine = _engine()
creds_svc = AsyncMock()
creds_svc.get_decrypted = AsyncMock(return_value=CREDS)
monkeypatch.setattr(
ti, "get_telegram_credentials_service", lambda _session: creds_svc
)
client = AsyncMock()
client.configured = True
# Two updates the engine ignores (no message/callback_query key) — only
# the offset bookkeeping is under test here.
client.get_updates = AsyncMock(
return_value=[{"update_id": 100}, {"update_id": 105}]
)
monkeypatch.setattr(engine, "_client", AsyncMock(return_value=client))
settings_svc = AsyncMock()
settings_svc.get_int = AsyncMock(return_value=0)
monkeypatch.setattr(ti, "get_settings_service", lambda _session: settings_svc)
await engine.run_cycle()
client.get_updates.assert_awaited_once_with(offset=None, timeout=25, limit=50)
settings_svc.set.assert_awaited_once_with("telegram_last_update_id", "106")
@pytest.mark.asyncio
async def test_run_cycle_requests_stored_offset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(ti.settings, "telegram_enabled", True)
monkeypatch.setattr(ti.settings, "telegram_inbound_enabled", True)
engine = _engine()
creds_svc = AsyncMock()
creds_svc.get_decrypted = AsyncMock(return_value=CREDS)
monkeypatch.setattr(
ti, "get_telegram_credentials_service", lambda _session: creds_svc
)
client = AsyncMock()
client.configured = True
client.get_updates = AsyncMock(return_value=[])
monkeypatch.setattr(engine, "_client", AsyncMock(return_value=client))
settings_svc = AsyncMock()
settings_svc.get_int = AsyncMock(return_value=42)
monkeypatch.setattr(ti, "get_settings_service", lambda _session: settings_svc)
await engine.run_cycle()
client.get_updates.assert_awaited_once_with(offset=42, timeout=25, limit=50)
# No updates seen -> offset must not regress/rewrite.
settings_svc.set.assert_not_called()
# ---------------------------------------------------------------------------
# _resolve_task — exact id-prefix match, ambiguity handling
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_task_exact_prefix_match(monkeypatch: pytest.MonkeyPatch) -> None:
engine = _engine()
task = _fake_task("a1b2c3d4")
task_svc = AsyncMock()
task_svc.search_tasks = AsyncMock(return_value=[task])
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
resolved = await engine._resolve_task("a1b2c3d4")
# Widened from 10 -> 50: a real id-prefix hit can otherwise be pushed out
# of a small window by title/description ILIKE hits on newer rows.
task_svc.search_tasks.assert_awaited_once_with("a1b2c3d4", limit=50)
assert resolved is task
@pytest.mark.asyncio
async def test_resolve_task_ambiguous_returns_none(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
# Both genuinely start with the same prefix -> ambiguous.
t1 = _fake_task("a1b2c3d4")
t2 = SimpleNamespace(id=UUID(hex="a1b2c3d4" + "0" * 24), title="dup")
task_svc = AsyncMock()
task_svc.search_tasks = AsyncMock(return_value=[t1, t2])
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
assert await engine._resolve_task("a1b2c3d4") is None
@pytest.mark.asyncio
async def test_resolve_task_filters_out_title_only_matches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""search_tasks also OR-matches title/description substrings; a hit whose
id does NOT start with the query must be filtered back out."""
engine = _engine()
real = _fake_task("a1b2c3d4")
title_hit = _fake_task("ffffffff", title="mentions a1b2c3d4 in the title")
task_svc = AsyncMock()
task_svc.search_tasks = AsyncMock(return_value=[real, title_hit])
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
assert await engine._resolve_task("a1b2c3d4") is real
# ---------------------------------------------------------------------------
# dispatch: one per approve-kind
# ---------------------------------------------------------------------------
def _stub_resolve(
monkeypatch: pytest.MonkeyPatch, engine: ti.TelegramInboundEngine, task: Any
) -> None:
monkeypatch.setattr(engine, "_resolve_task", AsyncMock(return_value=task))
@pytest.mark.asyncio
async def test_dispatch_approve_task_calls_ceo_approve_with_notes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
task_svc = AsyncMock()
task_svc.ceo_approve = AsyncMock(return_value=task)
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
notes = "Looks solid, shipping it now."
ok, _text = await engine._dispatch_approve("task", "a1b2c3d4", "", notes=notes)
task_svc.ceo_approve.assert_awaited_once_with(task.id, notes)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_approve_task_refuses_short_notes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
task_svc = AsyncMock()
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
ok, text = await engine._dispatch_approve("task", "a1b2c3d4", "", notes="too short")
task_svc.ceo_approve.assert_not_called()
assert ok is False
assert "20" in text
@pytest.mark.asyncio
async def test_dispatch_reject_task_calls_ceo_reject_with_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
task_svc = AsyncMock()
task_svc.ceo_reject = AsyncMock(return_value=task)
monkeypatch.setattr(ti, "get_task_service", lambda _session: task_svc)
ok, _text = await engine._dispatch_reject("task", "a1b2c3d4", "", "not good enough")
task_svc.ceo_reject.assert_awaited_once_with(task.id, "not good enough")
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_approve_release_dispatches_background(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
dispatch_mock = MagicMock()
monkeypatch.setattr(ti, "dispatch_approve", dispatch_mock)
factory = object()
monkeypatch.setattr(ti, "get_session_factory", lambda: factory)
ok, text = await engine._dispatch_approve("release", "a1b2c3d4", "", notes=None)
dispatch_mock.assert_called_once_with(task.id, factory)
assert ok is True
assert "background" in text
@pytest.mark.asyncio
async def test_dispatch_approve_release_refuses_when_cancelled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""dispatch_approve fires the release execute in the background with no
result to inspect, so a stale Approve on an already-rejected (CANCELLED)
proposal must be caught HERE, before dispatch — else the CEO sees a false
"dispatched" success while the service-level guard silently no-ops."""
engine = _engine()
task = _fake_task()
task.status = TaskStatus.CANCELLED
_stub_resolve(monkeypatch, engine, task)
dispatch_mock = MagicMock()
monkeypatch.setattr(ti, "dispatch_approve", dispatch_mock)
ok, text = await engine._dispatch_approve("release", "a1b2c3d4", "", notes=None)
dispatch_mock.assert_not_called()
assert ok is False
assert "already rejected" in text.lower()
@pytest.mark.asyncio
async def test_dispatch_reject_release_surfaces_already_completed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The reject-after-approve mirror guard: the service raises when the
proposal already published; the handler must map that to (False, ...)
instead of an uncaught exception (which `run_cycle`'s broad except would
swallow, leaving the CEO with no response at all)."""
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
release_svc = AsyncMock()
release_svc.reject = AsyncMock(
side_effect=ti._ReleaseDone(
"release proposal already published (COMPLETED); cannot be rejected"
)
)
monkeypatch.setattr(
ti, "get_release_proposal_service", lambda _session: release_svc
)
ok, text = await engine._dispatch_reject(
"release", "a1b2c3d4", "", "needs another migration check"
)
assert ok is False
assert "already published" in text
@pytest.mark.asyncio
async def test_dispatch_reject_release_calls_service_with_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
release_svc = AsyncMock()
release_svc.reject = AsyncMock(return_value=task)
monkeypatch.setattr(
ti, "get_release_proposal_service", lambda _session: release_svc
)
ok, _text = await engine._dispatch_reject(
"release", "a1b2c3d4", "", "needs another migration check"
)
release_svc.reject.assert_awaited_once_with(
task.id, "needs another migration check"
)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_reject_release_enforces_ten_char_floor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
release_svc = AsyncMock()
monkeypatch.setattr(
ti, "get_release_proposal_service", lambda _session: release_svc
)
ok, text = await engine._dispatch_reject("release", "a1b2c3d4", "", "short")
release_svc.reject.assert_not_called()
assert ok is False
assert "not recorded" in text
@pytest.mark.asyncio
async def test_dispatch_approve_xpost_calls_service(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
x_svc = AsyncMock()
x_svc.approve = AsyncMock(
return_value=XPostExecuteResult(status="posted", tweet_id="1", detail="ok")
)
monkeypatch.setattr(ti, "get_x_post_service", lambda _session: x_svc)
ok, _text = await engine._dispatch_approve("xpost", "a1b2c3d4", "", notes=None)
x_svc.approve.assert_awaited_once_with(task.id)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_reject_xpost_calls_service_with_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
x_svc = AsyncMock()
x_svc.reject = AsyncMock(return_value=task)
monkeypatch.setattr(ti, "get_x_post_service", lambda _session: x_svc)
ok, _text = await engine._dispatch_reject("xpost", "a1b2c3d4", "", "off-brand tone")
x_svc.reject.assert_awaited_once_with(task.id, "off-brand tone")
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_approve_video_calls_real_video_service(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
video_svc = AsyncMock()
video_svc.approve = AsyncMock(
return_value=VideoPostExecuteResult(
status="posted", posted={"x": "1"}, detail="ok"
)
)
monkeypatch.setattr(
engine, "_real_video_post_service", AsyncMock(return_value=video_svc)
)
ok, _text = await engine._dispatch_approve("video", "a1b2c3d4", "", notes=None)
video_svc.approve.assert_awaited_once_with(task.id)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_reject_video_calls_service_with_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
video_svc = AsyncMock()
video_svc.reject = AsyncMock(return_value=task)
monkeypatch.setattr(ti, "get_video_post_service", lambda _session: video_svc)
ok, _text = await engine._dispatch_reject("video", "a1b2c3d4", "", "wrong caption")
video_svc.reject.assert_awaited_once_with(task.id, "wrong caption")
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_approve_roadmap_calls_service_with_ceo_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
roadmap_svc = AsyncMock()
roadmap_svc.approve_item = AsyncMock(
return_value=RoadmapItemResult(
status="approved", item_id="item-2", materialized_task_id="x", detail="ok"
)
)
monkeypatch.setattr(ti, "get_roadmap_service", lambda _session: roadmap_svc)
ok, _text = await engine._dispatch_approve(
"roadmap", "a1b2c3d4", "item-2", notes=None
)
roadmap_svc.approve_item.assert_awaited_once_with(
task.id, "item-2", created_by=CEO_UUID
)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_reject_roadmap_calls_service_with_reason(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
task = _fake_task()
_stub_resolve(monkeypatch, engine, task)
roadmap_svc = AsyncMock()
roadmap_svc.reject_item = AsyncMock(
return_value=RoadmapItemResult(
status="rejected", item_id="item-2", materialized_task_id=None, detail="ok"
)
)
monkeypatch.setattr(ti, "get_roadmap_service", lambda _session: roadmap_svc)
ok, _text = await engine._dispatch_reject(
"roadmap", "a1b2c3d4", "item-2", "not aligned with strategy"
)
roadmap_svc.reject_item.assert_awaited_once_with(
task.id, "item-2", "not aligned with strategy"
)
assert ok is True
@pytest.mark.asyncio
async def test_dispatch_approve_unresolved_task_short_circuits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
engine = _engine()
_stub_resolve(monkeypatch, engine, None)
ok, text = await engine._dispatch_approve("xpost", "ffffffff", "", notes=None)
assert ok is False
assert "No such" in text
# ---------------------------------------------------------------------------
# audit marker
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_audit_adds_via_telegram_row() -> None:
engine = _engine()
task_id = uuid4()
engine._mark_audit("xpost", task_id, "approve", item_id="")
added = cast("MagicMock", engine.session.add).call_args.args[0]
assert added.event_type == "telegram.xpost.approve"
assert added.agent_id == CEO_UUID
assert added.target_id == task_id
assert added.details["via"] == "telegram"
@@ -0,0 +1,136 @@
"""Pure-function coverage for telegram_inbound: command parsing, callback
build/parse round-trip, and chat-id authorization. No I/O, no DB."""
from __future__ import annotations
import pytest
from roboco.config import settings as cfg
from roboco.services.telegram_inbound import (
ParsedCallback,
_authorized_chat,
build_action_keyboard,
build_callback,
parse_callback,
parse_command,
)
_CALLBACK_DATA_MAX_BYTES = 64 # mirrors Telegram's own callback_data cap
_KEYBOARD_ROW_LEN_NO_OPEN_BUTTON = 2 # Approve + Reject, no panel_base_url
class TestParseCommand:
def test_plain_command(self) -> None:
assert parse_command("/status") == ("status", "")
def test_command_with_args(self) -> None:
assert parse_command("/task abc12345 extra words") == (
"task",
"abc12345 extra words",
)
def test_strips_botname_suffix(self) -> None:
assert parse_command("/status@my_roboco_bot") == ("status", "")
def test_non_command_text_is_empty(self) -> None:
assert parse_command("hello there") == ("", "")
def test_empty_text_is_empty(self) -> None:
assert parse_command("") == ("", "")
def test_case_insensitive(self) -> None:
assert parse_command("/STATUS") == ("status", "")
class TestCallbackRoundTrip:
@pytest.mark.parametrize(
"action,kind,id8,extra",
[
("apv", "task", "a1b2c3d4", ""),
("rej", "release", "deadbeef", ""),
("apv", "xpost", "12345678", ""),
("rej", "video", "87654321", ""),
("apv", "roadmap", "abcdef12", "item-3"),
],
)
def test_build_then_parse_round_trips(
self, action: str, kind: str, id8: str, extra: str
) -> None:
data = build_callback(action, kind, id8, extra)
parsed = parse_callback(data)
assert parsed == ParsedCallback(action=action, kind=kind, id8=id8, extra=extra)
def test_callback_data_stays_under_64_bytes(self) -> None:
data = build_callback("apv", "roadmap", "a1b2c3d4", "item-99")
assert len(data.encode()) <= _CALLBACK_DATA_MAX_BYTES
def test_oversized_callback_raises(self) -> None:
with pytest.raises(ValueError, match="64 bytes"):
build_callback("apv", "roadmap", "a1b2c3d4", "x" * 60)
def test_parse_rejects_unknown_action(self) -> None:
assert parse_callback("nope:task:a1b2c3d4") is None
def test_parse_rejects_unknown_kind(self) -> None:
assert parse_callback("apv:bogus:a1b2c3d4") is None
def test_parse_rejects_malformed_shape(self) -> None:
assert parse_callback("apv:task") is None
assert parse_callback("apv:task:a1:b2:c3") is None
def test_parse_rejects_empty_string(self) -> None:
assert parse_callback("") is None
def test_parse_rejects_empty_id8(self) -> None:
assert parse_callback("apv:task:") is None
class TestActionKeyboard:
def test_builds_approve_reject_row(self) -> None:
kb = build_action_keyboard("task", "a1b2c3d4")
row = kb["inline_keyboard"][0]
assert row[0] == {
"text": "Approve",
"callback_data": "apv:task:a1b2c3d4",
}
assert row[1] == {
"text": "Reject",
"callback_data": "rej:task:a1b2c3d4",
}
def test_omits_open_button_without_panel_base_url(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "panel_base_url", "")
kb = build_action_keyboard("task", "a1b2c3d4")
assert len(kb["inline_keyboard"][0]) == _KEYBOARD_ROW_LEN_NO_OPEN_BUTTON
def test_includes_open_button_with_panel_base_url(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "panel_base_url", "https://panel.example.com")
kb = build_action_keyboard("task", "a1b2c3d4")
row = kb["inline_keyboard"][0]
assert row[2] == {
"text": "Open",
"url": "https://panel.example.com/tasks/a1b2c3d4",
}
def test_roadmap_deep_link_carries_no_item_id_it_points_at_the_cycle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "panel_base_url", "https://panel.example.com")
kb = build_action_keyboard("roadmap", "a1b2c3d4", "item-2")
row = kb["inline_keyboard"][0]
assert row[2]["url"] == "https://panel.example.com/overview"
assert row[0]["callback_data"] == "apv:roadmap:a1b2c3d4:item-2"
class TestAuthorizedChat:
def test_matching_chat_id_authorized(self) -> None:
assert _authorized_chat("12345", "12345") is True
def test_mismatched_chat_id_rejected(self) -> None:
assert _authorized_chat("99999", "12345") is False
def test_empty_chat_id_rejected(self) -> None:
assert _authorized_chat("", "12345") is False
@@ -349,6 +349,69 @@ async def test_approve_is_idempotent_second_call_is_noop(
assert len(tiktok_poster.calls) == 1
@pytest.mark.asyncio
async def test_approve_refuses_already_rejected_draft(
db_session: AsyncSession,
) -> None:
"""The chokepoint guard: approving a CANCELLED (already-rejected) draft
refuses and never calls either poster the reproduced bug (a stale
Approve after reject re-posting)."""
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
fake_engine = MagicMock()
fake_engine.reauthor_from_rejection = AsyncMock(return_value=None)
with (
_LOCKED[0],
_LOCKED[1],
patch(
"roboco.services.video_engine.get_video_engine",
return_value=fake_engine,
),
):
await _svc(db_session, x_poster=x_poster, tiktok_poster=tiktok_poster).reject(
_id(task), "not on-brand"
)
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert x_poster.calls == []
assert tiktok_poster.calls == []
await db_session.refresh(task)
assert task.status == TS.CANCELLED
@pytest.mark.asyncio
async def test_approve_rechecks_cancelled_under_lock_and_never_posts(
db_session: AsyncSession,
) -> None:
"""TOCTOU parity with the COMPLETED re-check: a concurrent reject cancels
the draft after our pre-lock read; the in-lock re-read must see CANCELLED
and short-circuit never posting a rejected draft."""
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
async def _win_the_race(_self: HeartbeatMutex) -> str:
task.status = TS.CANCELLED
await db_session.flush()
return "tok"
with (
patch.object(HeartbeatMutex, "acquire", _win_the_race),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert x_poster.calls == []
assert tiktok_poster.calls == []
@pytest.mark.asyncio
async def test_approve_concurrent_lock_held_returns_in_progress(
db_session: AsyncSession,
@@ -318,6 +318,55 @@ async def test_approve_rechecks_completed_under_lock_and_never_reposts(
assert client.calls == []
@pytest.mark.asyncio
async def test_approve_refuses_already_rejected_draft(
db_session: AsyncSession,
) -> None:
"""The chokepoint guard: approving a CANCELLED (already-rejected) draft
refuses and never calls the X client the reproduced bug (a stale
Approve after reject re-posting)."""
task = await _seed_draft(db_session)
await _svc(db_session).reject(_id(task), "not on-brand")
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert client.calls == []
await db_session.refresh(task)
assert task.status == TS.CANCELLED
@pytest.mark.asyncio
async def test_approve_rechecks_cancelled_under_lock_and_never_posts(
db_session: AsyncSession,
) -> None:
"""TOCTOU parity with the COMPLETED re-check: a concurrent reject cancels
the draft after our pre-lock read; the in-lock re-read must see CANCELLED
and short-circuit never posting a rejected draft."""
task = await _seed_draft(db_session)
client = _StubClient()
async def _win_the_race(_self: XPostService, _key: str) -> str:
task.status = TS.CANCELLED
await db_session.flush()
return "tok"
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", _win_the_race),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "already_rejected"
assert client.calls == []
@pytest.mark.asyncio
async def test_approve_concurrent_lock_held_returns_in_progress(
db_session: AsyncSession,