Files
roboco/roboco/services/telegram_inbound.py
T
a036c97985 fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking (#666)
* fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking

Three root causes behind the Mini App/bot showing wrong numbers:

Pricing: glm-5.2 gets a grounded per-token rate (z.ai published pricing,
$1.40/$4.40/$0.26 per 1M, source+date in the table comment) so a GLM
fleet day stops reporting $0.00 for half a million tokens; ungrounded
Ollama-Cloud models render "subscription (untracked)" instead of a bare
zero (is_ollama_cloud_model, consumed directly by the cockpit). Side
effect, intended and documented: honestly-priced GLM now trips the
downgrade-only comparator for new qa/documenter complexity pins.

Display timezone: the cockpit bucketed days in UTC for a GMT+2 operator.
New pure foundation module display_time (resolve_zone/local_date/
trailing_dates/day_bounds_utc, DST-correct with tests for the 23h/25h
days) + ROBOCO_DISPLAY_TIMEZONE (IANA-validated, default UTC); the
cockpit's spend/velocity series bucket raw session/completion rows by
the display zone. The UTC-keyed rollup table and the main dashboard are
deliberately untouched.

Agent activity: AgentTable.status was never set to ACTIVE and
current_task_id was never written anywhere — "active: 0, working: []"
was structurally permanent. Every claim path now marks the claimant
ACTIVE with rollback symmetry (_finalize_claim for dev/PM claims,
_qa_or_doc_claim for QA/doc/PR-gate claims, pr_review_claim for external
review) and every release path clears it (pass/fail QA, pr_pass/pr_fail,
complete_review, advance-to-PM-review, reaper unclaim, voluntary
unclaim, reassign retarget, pool divert, admin transitions, unblock
restore-to-in-progress). The bot's /status shares the cockpit's fleet
derivation so the two surfaces can't disagree. Known ceiling, commented:
one current_task_id column shows a multi-root coordinator PM's most
recent claim only.

Drill: sonnet develop -> sonnet adversarial (refuted the original
chokepoint coverage claim; QA/doc/reviewer paths were unwired) ->
correction round (wired them all + restored a dropped assertion, deleted
a dead helper and the dead subscription_billed field) -> review.

* fix(db): post_update on AgentTable.current_task breaks the flush cycle

agents.current_task_id and tasks.assigned_to reference each other, so a
flush touching both rows — every claim now marks its agent ACTIVE — is
an instance-level circular dependency SQLAlchemy cannot topologically
sort. The e2e smoke's full verb paths (12 tests) hit it; the unit and
integration suites never flush both dirty rows with relationships
loaded. post_update emits the FK as a second UPDATE, the canonical fix
for mutually-referencing rows.

* fix(budgets): enforce only explicitly-set budgets — no per-TaskType defaults

The per-TaskType default cap table blocked an unbudgeted coordination
root one opus planning turn in ($1.50 PLANNING default vs. real
coordination spend) — a false positive by design the moment the fleet
runs a priced model. Budgets are now explicit-input only:
effective_task_budget_usd returns None for an unset budget_usd, the
budget sweep skips enforcement (and never prices spend) on None, and
the unblock re-check passes on None so clearing the budget field is
itself a valid resolution. The project monthly cap stays as the
explicit-input fleet-wide backstop. Panel copy tells the truth
("No cap" placeholder; empty = uncapped), and the TaskType default
table plus its resolver are deleted.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:09:50 +02:00

1304 lines
54 KiB
Python

"""Telegram inbound V2 — commands (/status, /queue, /task) + actionable
approve/reject callback buttons.
Gated by ``telegram_inbound_enabled`` on top of V1's ``telegram_enabled`` —
both, plus stored credentials, are required before the poll loop does
anything (``run_cycle`` is otherwise a fast no-op). Every action a callback or
a command triggers calls the SAME service methods the CEO-gated HTTP routes
call (``TaskService.ceo_approve``/``ceo_reject``, ``ReleaseProposalService``,
``XPostService``, ``VideoPostService``, ``RoadmapService``) — never a
shortcut around their locks/idempotency, since chat-id authorization here IS
the CEO-identity check those routes' ``require_ceo_role`` performs.
Callback data is a compact ``apv|rej:<kind>:<id8>[:<extra>]`` string (Telegram
caps this at 64 bytes). A reject (any kind) and a task approve (mirrors the
HTTP route's >=20-char CEO note requirement) both need free text the button
alone can't carry, so those force_reply-prompt the CEO and hold a small
in-memory ``_PENDING_REPLIES`` map keyed by ``(chat_id, prompt_message_id)``
until the next matching reply consumes it (bounded TTL, per-process — an
orchestrator restart just drops a stale prompt, the CEO taps the button
again).
The getUpdates offset cursor persists in the existing ``system_settings`` KV
store (``telegram_last_update_id``) rather than a new table, so a restart
doesn't replay already-processed updates.
Every outbound send uses Telegram's HTML ``parse_mode`` for real hierarchy
(bold headers, ``<code>`` ids, named links) instead of the original flat
plain-text posture. That only stays injection-safe because EVERY dynamic
value — task titles, reasons, subjects, urls, team/kind names — is run
through ``_esc`` (``html.escape``) before it is interpolated; the only
unescaped HTML in any composed message is the static markup this module
writes itself. Telegram-HTML supports only a small tag subset (b/i/u/s/code/
pre/a/blockquote) — nothing else is ever emitted.
"""
from __future__ import annotations
import html
import re
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID
import structlog
from roboco.config import settings
from roboco.db.base import get_session_factory
from roboco.db.tables import AuditLogTable
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.content.validators import reject_trivial
from roboco.models.base import TaskStatus
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services import telegram_bridge as bridge
from roboco.services.base import BaseService, ValidationError
from roboco.services.project import get_project_service
from roboco.services.release_proposal import TaskAlreadyCompletedError as _ReleaseDone
from roboco.services.release_proposal import (
dispatch_approve,
get_release_proposal_service,
)
from roboco.services.roadmap_service import get_roadmap_service
from roboco.services.settings import get_settings_service
from roboco.services.task import get_task_service
from roboco.services.telegram_client import TelegramClient, build_telegram_client
from roboco.services.telegram_credentials import get_telegram_credentials_service
from roboco.services.tg_cockpit import get_tg_cockpit_service
from roboco.services.tiktok_client import build_tiktok_poster
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
from roboco.services.video_post_service import TaskAlreadyCompletedError as _VideoDone
from roboco.services.video_post_service import (
VideoCaptionTooLongError,
VideoPostService,
get_video_post_service,
)
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_post_service import TaskAlreadyCompletedError as _XPostDone
from roboco.services.x_post_service import XPostBodyTooLongError, get_x_post_service
from roboco.services.x_video_client import build_x_video_poster
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
from roboco.services.telegram_credentials import TelegramCredentialsData
logger = structlog.get_logger()
# Reuses the system_settings KV store instead of a dedicated table (see
# `roboco.services.settings._VALIDATORS` for the write-side int validator).
_OFFSET_KEY = "telegram_last_update_id"
_CEO_UUID = UUID(AGENT_UUIDS["ceo"])
_QUEUE_ITEM_CAP = 10
_MESSAGE_CHAR_LIMIT = 4096 # Telegram's own sendMessage text cap
_VALID_KINDS = ("task", "release", "xpost", "video", "roadmap", "intake", "proj")
# "sel" is the project-picker tap for /newtask; apv/rej stay the approve/
# reject pair everywhere else.
_VALID_ACTIONS = ("apv", "rej", "sel")
# Per-kind reject-reason floor, mirroring each HTTP route's request schema
# (ReleaseRejectRequest min_length=10, XPostRejectRequest/VideoPostRejectRequest/
# RoadmapRejectRequest min_length=4; ceo_reject has no route-level floor beyond
# TaskService.ceo_reject's own reject_trivial default).
_REJECT_MIN_CHARS = {"task": 1, "release": 10, "xpost": 4, "video": 4, "roadmap": 4}
_DEFAULT_REJECT_MIN_CHARS = (
4 # defense-in-depth fallback; every valid kind is listed above
)
# Mirrors ceo_approve_task's own `_MIN_NOTES_CHARS` — the HTTP route's audit-
# trail requirement for a CEO approval note.
_TASK_APPROVE_MIN_CHARS = 20
# /status render order — the lifecycle's actual flow (pending -> claimed ->
# in_progress -> paused|blocked -> the review-gate chain -> completed /
# cancelled), not TaskStatus's declaration order or an alphabetical dump.
_STATUS_ORDER: tuple[TaskStatus, ...] = (
TaskStatus.BACKLOG,
TaskStatus.PENDING,
TaskStatus.CLAIMED,
TaskStatus.IN_PROGRESS,
TaskStatus.PAUSED,
TaskStatus.BLOCKED,
TaskStatus.VERIFYING,
TaskStatus.NEEDS_REVISION,
TaskStatus.AWAITING_QA,
TaskStatus.AWAITING_DOCUMENTATION,
TaskStatus.AWAITING_PR_REVIEW,
TaskStatus.AWAITING_PM_REVIEW,
TaskStatus.AWAITING_CEO_APPROVAL,
TaskStatus.COMPLETED,
TaskStatus.CANCELLED,
)
# /queue + push-DM item rendering — (emoji, label) per kind.
_KIND_DISPLAY: dict[str, tuple[str, str]] = {
"release": ("🚀", "Release"),
"video": ("🎬", "Video"),
"xpost": ("✕", "Post"),
"roadmap": ("🗺️", "Roadmap"),
"task": ("📋", "Task"),
}
# The bot's command menu — single source for /help AND the Bot API
# ``setMyCommands`` registration (synced once per process on the first poll
# cycle), so the Telegram menu can never drift from what's implemented.
BOT_COMMANDS: tuple[dict[str, str], ...] = (
{"command": "status", "description": "Task pipeline snapshot"},
{"command": "queue", "description": "Approvals with one-tap buttons"},
{"command": "agents", "description": "Who's working on what"},
{"command": "blocked", "description": "Blocked + awaiting-you tasks"},
{"command": "usage", "description": "Today's token spend"},
{"command": "task", "description": "Task detail (id prefix or title)"},
{"command": "secretary", "description": "Chat with the chief-of-staff"},
{"command": "newtask", "description": "Draft a task with Intake"},
{"command": "end", "description": "End the current chat session"},
{"command": "help", "description": "This list"},
)
_HELP_TEXT = "<b>RoboCo commands</b>\n" + "\n".join(
f"/{c['command']}{c['description']}" for c in BOT_COMMANDS
)
# Line caps for the /blocked reply — a phone screen, not a report.
_BLOCKED_CAP = 8
def _esc(value: object) -> str:
"""HTML-escape any dynamic value before it lands in a Telegram HTML
message. Telegram-HTML has no safe-interpolation primitive of its own —
every dynamic string (task titles, reasons, URLs, ...) must be escaped by
hand before assembly. Static markup this module writes is the only
unescaped HTML."""
return html.escape(str(value), quote=False)
def _esc_attr(value: object) -> str:
"""Like ``_esc`` but also escapes quotes (``quote=True``). Every
``href="..."`` value sits inside an HTML attribute, not a text node — an
unescaped ``"`` in the value closes the attribute early and lets the rest
of the string inject arbitrary markup/attributes into the ``<a>`` tag."""
return html.escape(str(value), quote=True)
# Telegram-HTML's own supported tag subset (see module docstring) — the only
# tags ``_truncate`` ever needs to balance, since every dynamic value is
# escaped before assembly.
_TELEGRAM_TAGS = frozenset({"b", "i", "u", "s", "code", "pre", "a", "blockquote"})
_TAG_RE = re.compile(r"</?([a-z]+)(?:\s[^>]*)?>")
def _safe_boundary(cut: str) -> str:
"""Back a truncated slice off the last mid-tag (``<b>...<``) or
mid-entity (``&am``) boundary — Telegram's HTML parser rejects either
outright."""
last_lt, last_gt = cut.rfind("<"), cut.rfind(">")
if last_lt > last_gt: # an unclosed '<...' — back off before it
cut = cut[:last_lt]
last_amp, last_semi = cut.rfind("&"), cut.rfind(";")
if last_amp > last_semi: # an unclosed '&...' entity — back off before it
cut = cut[:last_amp]
return cut
def _closing_tags(text: str) -> str:
"""The Telegram-HTML tags still open at the end of ``text``, rendered as
closing tags in reverse (innermost-first) order."""
stack: list[str] = []
for m in _TAG_RE.finditer(text):
name = m.group(1)
if name not in _TELEGRAM_TAGS:
continue
if m.group(0).startswith("</"):
if stack and stack[-1] == name:
stack.pop()
else:
stack.append(name)
return "".join(f"</{name}>" for name in reversed(stack))
def _truncate(text: str, limit: int = _MESSAGE_CHAR_LIMIT) -> str:
"""Telegram's own sendMessage cap, applied to the FINAL assembled HTML
string. Every dynamic value is escaped before assembly (``_esc``), so the
only tags/entities present are the small fixed set this module emits —
but a naive slice can still land mid-tag, mid-entity, or (since the slice
point falls wherever the char count happens to land) inside an as-yet-
unclosed tag like ``<code>``, and Telegram's HTML parser rejects an
unbalanced message outright. Back off to the last safe tag/entity
boundary (``_safe_boundary``), then append whatever closing tags are
still owed (``_closing_tags``) — trimming further first if the closing
tags themselves would push past ``limit``.
"""
if len(text) <= limit:
return text
cut = _safe_boundary(text[: limit - 1])
closing = _closing_tags(cut)
while len(cut.rstrip()) + 1 + len(closing) > limit:
cut = _safe_boundary(cut[:-1])
closing = _closing_tags(cut)
return cut.rstrip() + "…" + closing
def parse_command(text: str) -> tuple[str, str]:
"""Split a message into ``(command, args)``; ``("", "")`` for non-command
text. Strips a ``@botname`` suffix (the group-chat command syntax)."""
stripped = (text or "").strip()
if not stripped.startswith("/"):
return "", ""
head, _, rest = stripped.partition(" ")
cmd = head[1:].split("@", 1)[0].lower()
return cmd, rest.strip()
@dataclass(frozen=True)
class ParsedCallback:
"""One ``apv:<kind>:<id8>[:<extra>]`` / ``rej:...`` callback_data, parsed."""
action: str # "apv" | "rej"
kind: str
id8: str
extra: str = ""
_CALLBACK_DATA_MAX_BYTES = 64 # Telegram's own callback_data cap
_CALLBACK_PARTS_NO_EXTRA = 3 # action:kind:id8
_CALLBACK_PARTS_WITH_EXTRA = 4 # action:kind:id8:extra (roadmap's item id)
def build_callback(action: str, kind: str, id8: str, extra: str = "") -> str:
"""Compact callback_data. Raises if it would exceed Telegram's 64-byte cap
(roadmap's ``item-N`` extras stay well under it in practice)."""
parts = [action, kind, id8]
if extra:
parts.append(extra)
data = ":".join(parts)
if len(data.encode()) > _CALLBACK_DATA_MAX_BYTES:
raise ValueError(
f"callback_data exceeds {_CALLBACK_DATA_MAX_BYTES} bytes: {data!r}"
)
return data
def parse_callback(data: str) -> ParsedCallback | None:
"""None on anything malformed/unrecognized — the caller just answers the
callback_query with a generic rejection rather than guessing intent."""
parts = (data or "").split(":")
if len(parts) not in (_CALLBACK_PARTS_NO_EXTRA, _CALLBACK_PARTS_WITH_EXTRA):
return None
action, kind, id8 = parts[0], parts[1], parts[2]
if action not in _VALID_ACTIONS or kind not in _VALID_KINDS or not id8:
return None
extra = parts[3] if len(parts) == _CALLBACK_PARTS_WITH_EXTRA else ""
return ParsedCallback(action=action, kind=kind, id8=id8, extra=extra)
def render_queue_item_text(kind: str, id8: str, extra: str, title: str) -> str:
"""One styled ``<emoji> <b>Kind</b> — <escaped title> <code>id8[:extra]</code>``
line. Shared by ``/queue``'s listing (``_send_queue``) and the
origination-time push DM (``NotificationDeliveryService.
notify_ceo_of_queue_item``) so a freshly-drafted item and the same item
later listed by ``/queue`` render identically — one renderer, two
callers."""
emoji, label = _KIND_DISPLAY.get(kind, ("📋", kind.title()))
suffix = f":{extra}" if extra else ""
return _truncate(
f"{emoji} <b>{label}</b> — {_esc(title)} <code>{_esc(id8 + suffix)}</code>"
)
# Deep-link target per kind — mirrors where each queue actually lives in the
# panel (release proposal + roadmap both surface on the Overview command
# center; X/video drafts on the Social queue).
_DEEP_LINK_PATH = {
"task": "/tasks/{id8}",
"release": "/overview",
"xpost": "/social",
"video": "/social",
"roadmap": "/overview",
}
def _deep_link(kind: str, id8: str) -> str:
base = settings.panel_base_url.rstrip("/")
if not base:
return ""
return f"{base}{_DEEP_LINK_PATH[kind].format(id8=id8)}"
def build_action_keyboard(kind: str, id8: str, extra: str = "") -> dict[str, Any]:
"""Approve / Reject / (Open, when panel_base_url is set) inline row."""
row: list[dict[str, str]] = [
{"text": "Approve", "callback_data": build_callback("apv", kind, id8, extra)},
{"text": "Reject", "callback_data": build_callback("rej", kind, id8, extra)},
]
link = _deep_link(kind, id8)
if link:
row.append({"text": "Open", "url": link})
return {"inline_keyboard": [row]}
def _authorized_chat(chat_id: str, creds_chat_id: str) -> bool:
"""The CEO's own stored chat id IS the identity check here — no
agent/session token exists on a Telegram update, so this is the equivalent
of every CEO-gated route's ``require_ceo_role``."""
return bool(chat_id) and str(chat_id) == str(creds_chat_id)
def _authorized_sender(sender: dict[str, Any] | None, chat_id: str) -> bool:
"""Defense-in-depth on top of ``_authorized_chat``: when the update
carries a ``from`` user, its id must ALSO equal the (already
chat-id-verified) credentialed chat id — the supported deployment is a
private 1:1 chat where ``from.id == chat.id == the CEO``. A present but
mismatched ``from.id`` (e.g. another member somehow posting into what's
assumed to be a private chat) is refused. Absent ``from`` keeps the prior
chat-id-only behavior."""
if not sender:
return True
sender_id = sender.get("id")
if sender_id is None:
return True
return str(sender_id) == str(chat_id)
@dataclass
class _PendingAction:
"""A force_reply prompt awaiting the CEO's free-text reply."""
kind: str
id8: str
extra: str
action: str # "approve" | "reject"
origin_message_id: int | None # the buttoned message to clean up after
expires_at: float # time.monotonic()-based
# Per-process, best-effort — mirrors XEngine's in-memory `_INFLIGHT_APPROVES`
# registry. Not durable by design: a restart drops any in-flight prompt, the
# CEO just taps the button again (cheap enough to not warrant a table).
_PENDING_REPLIES: dict[tuple[str, int], _PendingAction] = {}
def _callback_disposition(parsed: ParsedCallback) -> str:
"""How a tapped button routes: "bridge" (intake confirm/discard + the
/newtask project pick — no reason prompt), "prompt" (every reject, and a
task approve, need free text a button can't carry → force_reply), or
"approve" (act immediately)."""
if parsed.kind in ("intake", "proj"):
return "bridge"
if parsed.action == "rej" or (parsed.action == "apv" and parsed.kind == "task"):
return "prompt"
return "approve"
def _sweep_expired_replies() -> None:
now = time.monotonic()
for key in [k for k, v in _PENDING_REPLIES.items() if v.expires_at <= now]:
_PENDING_REPLIES.pop(key, None)
class TelegramInboundEngine(BaseService):
"""Poll Telegram for commands/callbacks and act on them."""
service_name = "telegram_inbound"
# Once-per-process latch for the setMyCommands menu sync — class-level
# because the engine is re-instantiated every poll cycle.
_commands_synced: ClassVar[bool] = False
def __init__(
self, session: AsyncSession, client: TelegramClient | None = None
) -> None:
super().__init__(session)
self._injected_client = client
async def _ensure_commands_menu(self, client: TelegramClient) -> None:
"""Sync the bot's command menu from BOT_COMMANDS, once per process."""
if TelegramInboundEngine._commands_synced:
return
await client.set_my_commands(list(BOT_COMMANDS))
TelegramInboundEngine._commands_synced = True
async def _client(self, creds: TelegramCredentialsData) -> TelegramClient:
if self._injected_client is not None:
return self._injected_client
return build_telegram_client(creds, timeout=settings.telegram_timeout_seconds)
# ---- poll cycle ----------------------------------------------------
async def run_cycle(self) -> None:
"""One poll pass: fetch updates since the stored offset, dispatch
each, advance the offset. Never raises — every step is best-effort;
a single bad update is logged and skipped rather than wedging the
whole cycle (and the offset still advances past it)."""
if not (settings.telegram_enabled and settings.telegram_inbound_enabled):
return
creds = await get_telegram_credentials_service(self.session).get_decrypted()
if creds is None:
return
client = await self._client(creds)
if not client.configured:
return
await self._ensure_commands_menu(client)
offset = await get_settings_service(self.session).get_int(_OFFSET_KEY, 0)
cap = settings.telegram_max_updates_per_cycle
updates = await client.get_updates(
offset=offset or None,
timeout=settings.telegram_poll_timeout_seconds,
limit=cap,
)
max_seen = offset - 1
for update in updates[:cap]:
update_id = int(update.get("update_id") or 0)
max_seen = max(max_seen, update_id)
try:
await self._process_update(update, creds, client)
except Exception:
self.log.exception(
"telegram update processing failed", update_id=update_id
)
if max_seen >= offset:
await get_settings_service(self.session).set(_OFFSET_KEY, str(max_seen + 1))
await self.session.commit()
await bridge.sweep_idle()
async def _process_update(
self,
update: dict[str, Any],
creds: TelegramCredentialsData,
client: TelegramClient,
) -> None:
if "callback_query" in update:
await self._handle_callback(update["callback_query"], creds, client)
elif "message" in update:
await self._handle_message(update["message"], creds, client)
# else: edited_message / channel_post / other update kinds — ignored.
# ---- commands --------------------------------------------------------
async def _handle_message(
self,
message: dict[str, Any],
creds: TelegramCredentialsData,
client: TelegramClient,
) -> None:
chat = message.get("chat") or {}
chat_id = str(chat.get("id", ""))
if not _authorized_chat(chat_id, creds.chat_id):
self.log.debug(
"telegram message from unauthorized chat dropped", chat_id=chat_id
)
return
if not _authorized_sender(message.get("from"), chat_id):
self.log.debug(
"telegram message from mismatched sender dropped", chat_id=chat_id
)
return
text = str(message.get("text") or "")
reply_to = message.get("reply_to_message") or {}
reply_to_id = reply_to.get("message_id")
if reply_to_id is not None:
pending = _PENDING_REPLIES.pop((chat_id, int(reply_to_id)), None)
if pending is not None:
if pending.expires_at > time.monotonic():
await self._consume_reply(pending, text, client)
return
await client.send_message(
"That prompt expired — tap the button again.", parse_mode="HTML"
)
return
cmd, args = parse_command(text)
if not cmd:
await self._route_free_text(chat_id, text, client)
return
await self._dispatch_command(cmd, args, client, chat_id=chat_id, creds=creds)
async def _route_free_text(
self, chat_id: str, text: str, client: TelegramClient
) -> None:
"""While a bridged /secretary or /newtask session is live, plain
messages ARE the conversation; otherwise free text stays ignored."""
routed = await bridge.deliver_text(chat_id, text)
if routed:
await client.send_message(_esc(routed), parse_mode="HTML")
async def _dispatch_command(
self,
cmd: str,
args: str,
client: TelegramClient,
chat_id: str = "",
creds: TelegramCredentialsData | None = None,
) -> None:
if creds is not None and await self._dispatch_bridge_command(
cmd, args, client, chat_id, creds
):
return
if cmd in ("start", "help"):
await client.send_message(_HELP_TEXT, parse_mode="HTML")
elif cmd == "status":
await client.send_message(await self._render_status(), parse_mode="HTML")
elif cmd == "queue":
await self._send_queue(client)
elif cmd == "task":
await client.send_message(
await self._render_task(args),
parse_mode="HTML",
disable_link_preview=True,
)
elif cmd == "agents":
await client.send_message(await self._render_agents(), parse_mode="HTML")
elif cmd == "usage":
await client.send_message(await self._render_usage(), parse_mode="HTML")
elif cmd == "blocked":
await client.send_message(
await self._render_blocked(),
parse_mode="HTML",
disable_link_preview=True,
)
else:
await client.send_message(
f"Unknown command /{_esc(cmd)}.\n\n{_HELP_TEXT}", parse_mode="HTML"
)
async def _render_status(self) -> str:
"""Cheap snapshot: fleet status breakdown + task counts by status —
no spend/strategy/pitch queries (that's the heavier cockpit summary).
The fleet breakdown shares the SAME by_status derivation as
``/agents`` and the Today brief (``TgCockpitService.fleet`` ->
``DashboardService.get_all_agent_status``) so the three surfaces can
never disagree on what "active" means. Task statuses render in
lifecycle order (only the nonzero ones), not alphabetically — a CEO
scanning on a phone reads top-to-bottom as the pipeline, not as an
a-z dump."""
counts = await get_task_service(self.session).count_by_status()
fleet = await get_tg_cockpit_service(self.session).fleet()
by_status = fleet.get("by_status", {})
fleet_line = " · ".join(
f"<b>{by_status.get(status, 0)}</b> {status}"
for status in ("active", "idle", "offline")
)
lines = [
"<b>🤖 Fleet</b>",
fleet_line,
"",
"<b>📋 Tasks</b>",
]
lines += [
f"• {status.value} — <b>{counts[status.value]}</b>"
for status in _STATUS_ORDER
if counts.get(status.value)
]
return _truncate("\n".join(lines))
async def _dispatch_bridge_command(
self,
cmd: str,
args: str,
client: TelegramClient,
chat_id: str,
creds: TelegramCredentialsData,
) -> bool:
"""The /secretary, /newtask, and /end verbs — True when handled."""
if cmd == "secretary":
await client.send_message(
await self._cmd_secretary(chat_id, args.strip(), creds),
parse_mode="HTML",
)
return True
if cmd == "newtask":
await self._cmd_newtask(chat_id, args.strip(), creds, client)
return True
if cmd == "end":
await client.send_message(
_esc(await bridge.end_session(chat_id)), parse_mode="HTML"
)
return True
return False
async def _cmd_secretary(
self, chat_id: str, text: str, creds: TelegramCredentialsData
) -> str:
sess = bridge.active_session(chat_id)
if sess is not None and sess.kind == "secretary":
if not text:
return "Already chatting — just type."
return await bridge.deliver_text(chat_id, text) or "Delivered."
if sess is not None:
return "You're mid /newtask interview — /end it first."
return await bridge.start_secretary(chat_id, text, creds)
async def _cmd_newtask(
self,
chat_id: str,
text: str,
creds: TelegramCredentialsData,
client: TelegramClient,
) -> None:
if bridge.active_session(chat_id) is not None:
await client.send_message(
"A chat session is already active — /end it first.",
)
return
projects = await get_project_service(self.session).list_all()
if not projects:
await client.send_message("No projects registered yet.")
return
if len(projects) == 1:
message = await bridge.start_intake(
chat_id, text, creds, project=projects[0]
)
await client.send_message(message, parse_mode="HTML")
return
bridge.remember_newtask_text(chat_id, text)
keyboard = {
"inline_keyboard": [
[
{
"text": p.name,
"callback_data": build_callback("sel", "proj", str(p.id)[:8]),
}
]
for p in projects[:_QUEUE_ITEM_CAP]
]
}
await client.send_message("Which project?", reply_markup=keyboard)
async def _handle_bridge_callback(
self,
parsed: ParsedCallback,
chat_id: str,
origin_message_id: int | None,
creds: TelegramCredentialsData,
client: TelegramClient,
) -> None:
"""The intake draft confirm/discard + /newtask project-pick taps.
The callback_query is already answered by the caller."""
if parsed.kind == "proj":
await self._start_picked_intake(parsed.id8, chat_id, creds, client)
return
if parsed.action == "apv":
ok, text = await self._confirm_intake_draft(chat_id)
await self._finish_action(client, origin_message_id, ok, text)
return
bridge.discard_draft(chat_id)
await self._finish_action(
client,
origin_message_id,
True,
"Draft discarded — keep chatting to revise it, or /end.",
)
async def _start_picked_intake(
self,
id8: str,
chat_id: str,
creds: TelegramCredentialsData,
client: TelegramClient,
) -> None:
projects = await get_project_service(self.session).list_all()
picked = next((p for p in projects if str(p.id).startswith(id8)), None)
if picked is None:
await client.send_message("That project no longer exists.")
return
message = await bridge.start_intake(
chat_id, bridge.pop_newtask_text(chat_id), creds, project=picked
)
await client.send_message(message, parse_mode="HTML")
async def _confirm_intake_draft(self, chat_id: str) -> tuple[bool, str]:
"""Route the pending draft through the normal board-review confirm
(the panel's own service seam), then park the session so board
feedback streams back into this thread."""
from roboco.services.prompter import get_prompter_service
sess = bridge.active_session(chat_id)
if sess is None or sess.pending_draft is None:
return False, "No draft is pending."
try:
task_id = await get_prompter_service(self.session).confirm_live_draft(
sess.pending_draft,
_CEO_UUID,
project_id=UUID(sess.project_id) if sess.project_id else None,
route="board",
)
await self.session.commit()
except Exception as exc:
self.log.exception("telegram intake confirm failed", chat_id=chat_id)
return False, f"Confirm failed: {exc}"
bridge.mark_parked(chat_id, str(task_id))
return True, (
"Sent to Board review 📋 — the board's feedback will land right "
"here in this chat."
)
async def _render_agents(self) -> str:
"""Who's mid-task right now — the Today brief's fleet block as text."""
fleet = await get_tg_cockpit_service(self.session).fleet()
by_status = ", ".join(
f"{count} {status}"
for status, count in sorted(fleet.get("by_status", {}).items())
)
header = f"<b>🤖 Agents</b> — {fleet.get('total', 0)} total"
if by_status:
header += f" ({_esc(by_status)})"
lines = [header]
working = fleet.get("working", [])
if not working:
lines.append("No one is mid-task.")
for agent in working:
task_title = agent.get("task_title")
suffix = f" — {_esc(task_title)}" if task_title else ""
lines.append(f"• <b>{_esc(agent.get('name', ''))}</b>{suffix}")
return _truncate("\n".join(lines))
async def _render_usage(self) -> str:
"""Today's spend — the Today brief's own number (display-timezone
bucketed), so the bot and the Mini App never disagree on "today"."""
summary = await get_tg_cockpit_service(self.session).today_spend()
tokens = int(summary.get("tokens_today", 0))
if summary.get("subscription_billed"):
return (
f"<b>💸 Spend today</b>\n≈$0 — subscription (untracked) · "
f"{tokens:,} tokens"
)
cost = float(summary.get("cost_today_usd", 0.0))
return f"<b>💸 Spend today</b>\n${cost:,.2f} · {tokens:,} tokens"
def _task_link_line(self, task: TaskTable) -> str:
id8 = str(task.id)[:8]
link = _deep_link("task", id8)
title = _esc(task.title)
label = f'<a href="{_esc_attr(link)}">{title}</a>' if link else title
return f"• {label} <code>{id8}</code>"
async def _render_blocked(self) -> str:
"""Blocked + awaiting-CEO tasks, deep-linked, capped per section."""
tasks = get_task_service(self.session)
awaiting = await tasks.list_awaiting_ceo_approval()
blocked = await tasks.list_blocked()
if not awaiting and not blocked:
return "Nothing is blocked or awaiting you. 🎉"
lines: list[str] = []
if awaiting:
lines.append("<b>🖊️ Awaiting you</b>")
lines += [self._task_link_line(t) for t in awaiting[:_BLOCKED_CAP]]
if blocked:
if lines:
lines.append("")
lines.append("<b>⛔ Blocked</b>")
lines += [self._task_link_line(t) for t in blocked[:_BLOCKED_CAP]]
return _truncate("\n".join(lines))
async def _collect_queue_items(self) -> list[tuple[str, str, str, str]]:
"""``(kind, id8, extra, title)`` for everything awaiting the CEO —
reuses the exact list/open methods the panel's own queues call. One
small per-kind gatherer each, rather than one long branchy loop."""
items: list[tuple[str, str, str, str]] = []
items += await self._queue_items_for_tasks()
items += await self._queue_items_for_release()
items += await self._queue_items_for_xposts()
items += await self._queue_items_for_videos()
items += await self._queue_items_for_roadmap()
return items
async def _queue_items_for_tasks(self) -> list[tuple[str, str, str, str]]:
tasks = await get_task_service(self.session).list_awaiting_ceo_approval()
return [("task", str(t.id)[:8], "", t.title or "Untitled") for t in tasks]
async def _queue_items_for_release(self) -> list[tuple[str, str, str, str]]:
proposal = await get_release_proposal_service(self.session).open_proposal()
if proposal is None:
return []
id8 = str(proposal.id)[:8]
report = markers.get_release_report(proposal) or {}
version = report.get("proposed_version") or "?"
return [("release", id8, "", f"v{version} ready")]
async def _queue_items_for_xposts(self) -> list[tuple[str, str, str, str]]:
posts = await get_x_post_service(self.session).list_open_posts()
result = []
for post in posts:
id8 = str(post.id)[:8]
body = markers.get_x_draft_body(post) or post.description or ""
result.append(("xpost", id8, "", body[:100]))
return result
async def _queue_items_for_videos(self) -> list[tuple[str, str, str, str]]:
posts = await get_video_post_service(self.session).list_held_video_posts()
result = []
for post in posts:
id8 = str(post.id)[:8]
draft = markers.get_video_draft(post) or {}
occasion = draft.get("occasion") or "untitled"
result.append(("video", id8, "", occasion))
return result
async def _queue_items_for_roadmap(self) -> list[tuple[str, str, str, str]]:
cycles = await get_roadmap_service(self.session).list_open_cycles()
result = []
for cycle in cycles:
id8 = str(cycle.id)[:8]
payload = markers.get_roadmap_cycle(cycle) or {}
result += self._proposed_roadmap_items(id8, payload)
return result
def _proposed_roadmap_items(
self, id8: str, payload: dict[str, Any]
) -> list[tuple[str, str, str, str]]:
result = []
for item in payload.get("items", []):
if item.get("status") != "proposed":
continue
item_id = str(item.get("id") or "")
title = item.get("title") or "untitled"
result.append(("roadmap", id8, item_id, title))
return result
async def _send_queue(self, client: TelegramClient) -> None:
items = await self._collect_queue_items()
if not items:
await client.send_message(
"✅ Nothing awaiting your approval.", parse_mode="HTML"
)
return
noun = "item" if len(items) == 1 else "items"
await client.send_message(
f"<b>🔔 Awaiting your approval</b> — {len(items)} {noun}",
parse_mode="HTML",
)
for kind, id8, extra, title in items[:_QUEUE_ITEM_CAP]:
result = await client.send_message(
render_queue_item_text(kind, id8, extra, title),
reply_markup=build_action_keyboard(kind, id8, extra),
parse_mode="HTML",
)
if not result.sent:
logger.warning(
"telegram queue item send failed",
kind=kind,
id8=id8,
detail=result.detail,
)
async def _resolve_task(self, id8: str) -> TaskTable | None:
"""Exact id-prefix resolution — ``search_tasks`` also OR-matches
title/description substrings, so filter back down to only rows whose
id genuinely starts with ``id8``. None on zero or ambiguous (>1)
matches; downstream callers surface that as "no such item".
A generous (but still bounded) limit: a real id-prefix hit could
otherwise be pushed out of a small window by title/description ILIKE
hits on newer rows, making a genuine single match look ambiguous or
vanish entirely.
"""
candidates = await get_task_service(self.session).search_tasks(id8, limit=50)
exact = [t for t in candidates if str(t.id).startswith(id8)]
return exact[0] if len(exact) == 1 else None
async def _render_task(self, args: str) -> str:
q = args.strip()
if not q:
return "Usage: /task id8-or-title-fragment"
task = await self._resolve_task(q)
if task is None:
resolved_or_error = await self._search_task_by_fragment(q)
if isinstance(resolved_or_error, str):
return resolved_or_error
task = resolved_or_error
return _truncate(self._format_task_detail(task))
async def _search_task_by_fragment(self, q: str) -> TaskTable | str:
"""A title/description-fragment fallback for ``/task`` when the query
isn't a resolvable id prefix. Returns either the single match, or a
rendered error/disambiguation message for the caller to return as-is."""
candidates = await get_task_service(self.session).search_tasks(q, limit=5)
if not candidates:
return f"No task matches {_esc(q)!r}."
if len(candidates) > 1:
listing = "\n".join(
f"• <code>{_esc(str(t.id)[:8])}</code> — {_esc(t.title)}"
for t in candidates
)
return (
f"Multiple matches for {_esc(q)!r}:\n{listing}\n\n"
"Retry with a more specific id or title."
)
return candidates[0]
def _format_task_detail(self, task: TaskTable) -> str:
id8 = str(task.id)[:8]
status_val = (
task.status.value if hasattr(task.status, "value") else str(task.status)
)
team_val = (
task.team.value if task.team and hasattr(task.team, "value") else "n/a"
)
lines = [
f"<b><code>{_esc(id8)}</code> {_esc(task.title or 'Untitled')}</b>",
f"Status: <b>{_esc(status_val)}</b>",
f"Team: {_esc(team_val)}",
]
if task.pr_url:
lines.append(f'PR: <a href="{_esc_attr(task.pr_url)}">View PR</a>')
link = _deep_link("task", id8)
if link:
lines.append(f'<a href="{_esc_attr(link)}">Open in panel</a>')
return "\n".join(lines)
# ---- callback (button) handling ---------------------------------------
async def _handle_callback(
self, cq: dict[str, Any], creds: TelegramCredentialsData, client: TelegramClient
) -> None:
origin = cq.get("message") or {}
chat = origin.get("chat") or {}
chat_id = str(chat.get("id", ""))
cq_id = str(cq.get("id", ""))
if not _authorized_chat(chat_id, creds.chat_id):
await client.answer_callback_query(cq_id, "Not authorized")
return
if not _authorized_sender(cq.get("from"), chat_id):
await client.answer_callback_query(cq_id, "Not authorized")
return
parsed = parse_callback(str(cq.get("data") or ""))
origin_message_id = origin.get("message_id")
if parsed is None:
await client.answer_callback_query(cq_id, "Unrecognized action")
return
disposition = _callback_disposition(parsed)
if disposition == "bridge":
await client.answer_callback_query(cq_id, "Working...")
await self._handle_bridge_callback(
parsed, chat_id, origin_message_id, creds, client
)
return
if disposition == "prompt":
await client.answer_callback_query(cq_id, "Reply with the reason")
await self._prompt_for_reply(parsed, chat_id, origin_message_id, client)
return
await client.answer_callback_query(cq_id, "Working...")
ok, text = await self._dispatch_approve(
parsed.kind, parsed.id8, parsed.extra, notes=None
)
await self._finish_action(client, origin_message_id, ok, text)
async def _prompt_for_reply(
self,
parsed: ParsedCallback,
chat_id: str,
origin_message_id: int | None,
client: TelegramClient,
) -> None:
field = (
f"approval notes (at least {_TASK_APPROVE_MIN_CHARS} chars)"
if parsed.action == "apv"
else "rejection reason"
)
target = f"{parsed.kind}:{parsed.id8}" + (
f":{parsed.extra}" if parsed.extra else ""
)
prompt = await client.send_message(
f"Reply to THIS message with your {field} for <code>{_esc(target)}</code>.",
reply_markup={"force_reply": True},
parse_mode="HTML",
)
if prompt.message_id is None:
return
_sweep_expired_replies()
_PENDING_REPLIES[(chat_id, prompt.message_id)] = _PendingAction(
kind=parsed.kind,
id8=parsed.id8,
extra=parsed.extra,
action="approve" if parsed.action == "apv" else "reject",
origin_message_id=(
int(origin_message_id) if origin_message_id is not None else None
),
expires_at=time.monotonic() + settings.telegram_pending_reply_ttl_seconds,
)
async def _consume_reply(
self, pending: _PendingAction, text: str, client: TelegramClient
) -> None:
if pending.action == "reject":
ok, result_text = await self._dispatch_reject(
pending.kind, pending.id8, pending.extra, text
)
else:
ok, result_text = await self._dispatch_approve(
pending.kind, pending.id8, pending.extra, notes=text
)
await self._finish_action(client, pending.origin_message_id, ok, result_text)
async def _finish_action(
self, client: TelegramClient, origin_message_id: int | None, ok: bool, text: str
) -> None:
"""Clears the original message's button row and stamps the outcome —
the chat stays honest instead of leaving a stale, still-clickable
Approve/Reject row after the action already happened. ``text`` is the
single funnel every approve/reject outcome (across all five kinds)
flows through, so it's escaped once here rather than at each of the
dozen call sites that compose it — it may embed a task title, a
rejection reason, or a service-raised error message, all dynamic."""
icon = "✅" if ok else "❌"
rendered = _truncate(f"{icon} {_esc(text)}")
if origin_message_id is not None:
await client.edit_message_reply_markup(int(origin_message_id), None)
await client.edit_message_text(
int(origin_message_id), rendered, parse_mode="HTML"
)
else:
await client.send_message(rendered, parse_mode="HTML")
def _mark_audit(
self, kind: str, task_id: UUID, action: str, *, item_id: str = ""
) -> None:
"""One extra AuditLogTable row (no schema change — reused table)
tagging the action `via=telegram`, alongside whatever audit trail the
underlying service call itself already produces."""
details: dict[str, Any] = {"via": "telegram", "kind": kind, "action": action}
if item_id:
details["item_id"] = item_id
self.session.add(
AuditLogTable(
event_type=f"telegram.{kind}.{action}",
agent_id=_CEO_UUID,
target_type="task",
target_id=task_id,
severity="info",
details=details,
)
)
async def _real_video_post_service(self) -> VideoPostService:
"""Mirrors the video route's own ``_real_video_post_service`` — only
approve needs live posters; list/reject use the inert Null default."""
x_creds = await get_x_credentials_service(self.session).get_decrypted()
x_poster = build_x_video_poster(
x_creds, timeout=settings.x_request_timeout_seconds
)
tiktok_creds = await get_tiktok_credentials_service(
self.session
).get_decrypted()
tiktok_poster = build_tiktok_poster(
tiktok_creds,
session=self.session,
timeout=settings.video_request_timeout_seconds,
)
return get_video_post_service(
self.session, x_poster=x_poster, tiktok_poster=tiktok_poster
)
# ---- dispatch: same service methods the CEO-gated HTTP routes call ----
#
# One small `_approve_<kind>`/`_reject_<kind>` handler per kind, looked up
# by a dict rather than an if/elif ladder — keeps each handler (and the
# two dispatchers themselves) well under the branch/return-count budget
# a 5-kind if/elif chain would blow through.
async def _dispatch_approve(
self, kind: str, id8: str, extra: str, *, notes: str | None
) -> tuple[bool, str]:
task = await self._resolve_task(id8)
if task is None:
return False, f"No such {kind} item: {id8}"
handler = {
"task": self._approve_task,
"release": self._approve_release,
"xpost": self._approve_xpost,
"video": self._approve_video,
"roadmap": self._approve_roadmap,
}.get(kind)
if handler is None:
return False, f"Unknown kind: {kind}"
return await handler(task, id8, extra, notes)
async def _approve_task(
self, task: TaskTable, id8: str, _extra: str, notes: str | None
) -> tuple[bool, str]:
if not notes or len(notes.strip()) < _TASK_APPROVE_MIN_CHARS:
return False, (
f"Approval needs notes >= {_TASK_APPROVE_MIN_CHARS} chars — "
"tap Approve again and reply."
)
task_id = cast("UUID", task.id)
result = await get_task_service(self.session).ceo_approve(
task_id, notes.strip()
)
if result is None:
return False, f"Could not approve {id8} (not awaiting CEO approval)."
self._mark_audit("task", task_id, "approve")
await self.session.commit()
return True, f"Approved: {task.title or id8}"
async def _approve_release(
self, task: TaskTable, id8: str, _extra: str, _notes: str | None
) -> tuple[bool, str]:
# dispatch_approve fires the ~40min execute in the background and
# returns immediately with no result to inspect — so a refusal (e.g.
# a stale Approve button on a proposal the CEO already rejected) has
# to be checked HERE, before dispatch, or the CEO would see a false
# "dispatched" success while the background task silently no-ops on
# the service's own CANCELLED guard.
if task.status == TaskStatus.CANCELLED:
return False, "This release proposal was already rejected."
if task.status == TaskStatus.COMPLETED:
return False, "This release was already approved and published."
task_id = cast("UUID", task.id)
dispatch_approve(task_id, get_session_factory())
self._mark_audit("release", task_id, "approve")
await self.session.commit()
return (
True,
f"Release approval dispatched for {id8} (publishing in background).",
)
async def _approve_xpost(
self, task: TaskTable, id8: str, _extra: str, _notes: str | None
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
try:
result = await get_x_post_service(self.session).approve(task_id)
except XPostBodyTooLongError as exc:
return False, str(exc)
if result is None:
return False, f"No such open X draft: {id8}"
self._mark_audit("xpost", task_id, "approve")
await self.session.commit()
ok = result.status in ("posted", "already_posted")
return ok, f"X post {result.status}: {result.detail}"
async def _approve_video(
self, task: TaskTable, id8: str, _extra: str, _notes: str | None
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
svc = await self._real_video_post_service()
try:
result = await svc.approve(task_id)
except VideoCaptionTooLongError as exc:
return False, str(exc)
if result is None:
return False, f"No such open video draft: {id8}"
self._mark_audit("video", task_id, "approve")
await self.session.commit()
ok = result.status in ("posted", "posted_partial", "already_posted")
return ok, f"Video {result.status}: {result.detail}"
async def _approve_roadmap(
self, task: TaskTable, id8: str, extra: str, _notes: str | None
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
result = await get_roadmap_service(self.session).approve_item(
task_id, extra, created_by=_CEO_UUID
)
if result is None:
return False, f"No such roadmap item: {id8}:{extra}"
self._mark_audit("roadmap", task_id, "approve", item_id=extra)
await self.session.commit()
ok = result.status in ("approved", "already_approved")
return ok, f"Roadmap item {result.status}: {result.detail}"
async def _dispatch_reject(
self, kind: str, id8: str, extra: str, reason: str
) -> tuple[bool, str]:
try:
clean_reason = reject_trivial(
reason,
field="reason",
min_chars=_REJECT_MIN_CHARS.get(kind, _DEFAULT_REJECT_MIN_CHARS),
)
except ValueError as exc:
return False, f"Rejection not recorded: {exc}"
task = await self._resolve_task(id8)
if task is None:
return False, f"No such {kind} item: {id8}"
handler = {
"task": self._reject_task,
"release": self._reject_release,
"xpost": self._reject_xpost,
"video": self._reject_video,
"roadmap": self._reject_roadmap,
}.get(kind)
if handler is None:
return False, f"Unknown kind: {kind}"
return await handler(task, id8, extra, clean_reason)
async def _reject_task(
self, task: TaskTable, id8: str, _extra: str, reason: str
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
try:
result = await get_task_service(self.session).ceo_reject(task_id, reason)
except ValidationError as exc:
return False, f"Rejection not recorded: {exc}"
if result is None:
return False, f"Could not reject {id8} (not awaiting CEO approval)."
self._mark_audit("task", task_id, "reject")
await self.session.commit()
return True, f"Rejected: {task.title or id8}"
async def _reject_release(
self, task: TaskTable, id8: str, _extra: str, reason: str
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
try:
result = await get_release_proposal_service(self.session).reject(
task_id, reason
)
except _ReleaseDone as exc:
return False, str(exc)
if result is None:
return False, f"No such open release proposal: {id8}"
self._mark_audit("release", task_id, "reject")
await self.session.commit()
return True, f"Release proposal {id8} rejected."
async def _reject_xpost(
self, task: TaskTable, id8: str, _extra: str, reason: str
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
try:
result = await get_x_post_service(self.session).reject(task_id, reason)
except _XPostDone as exc:
return False, str(exc)
if result is None:
return False, f"No such open X draft: {id8}"
self._mark_audit("xpost", task_id, "reject")
await self.session.commit()
return True, f"X draft {id8} rejected."
async def _reject_video(
self, task: TaskTable, id8: str, _extra: str, reason: str
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
try:
result = await get_video_post_service(self.session).reject(task_id, reason)
except _VideoDone as exc:
return False, str(exc)
if result is None:
return False, f"No such open video draft: {id8}"
self._mark_audit("video", task_id, "reject")
await self.session.commit()
return True, f"Video draft {id8} rejected."
async def _reject_roadmap(
self, task: TaskTable, id8: str, extra: str, reason: str
) -> tuple[bool, str]:
task_id = cast("UUID", task.id)
result = await get_roadmap_service(self.session).reject_item(
task_id, extra, reason
)
if result is None:
return False, f"No such roadmap item: {id8}:{extra}"
self._mark_audit("roadmap", task_id, "reject", item_id=extra)
await self.session.commit()
ok = result.status in ("rejected", "already_rejected")
return ok, f"Roadmap item {result.status}: {result.detail}"
def get_telegram_inbound_engine(
session: AsyncSession, client: TelegramClient | None = None
) -> TelegramInboundEngine:
"""Construct a TelegramInboundEngine bound to ``session``."""
return TelegramInboundEngine(session, client)