From 3995741cd59da9fda67b2763f0bc1100ece039b3 Mon Sep 17 00:00:00 2001 From: h Date: Fri, 28 Aug 2026 18:25:46 +0200 Subject: [PATCH] fix(telegram,core): first message seeds the branch, registered commands, no ellipses, html drafts --- src/beaver_gateway/core/conversations.py | 2 +- .../frontends/telegram/drafts.py | 27 ++++--- .../frontends/telegram/frontend.py | 79 ++++++++++++------- .../frontends/telegram/render.py | 42 +++++----- tests/test_telegram.py | 39 ++++----- 5 files changed, 111 insertions(+), 78 deletions(-) diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index 8c5ee44..afd7d28 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -535,7 +535,7 @@ class Conversations: await self._queue.push( conversation_id=cast("int", conv.id), priority="user", - origin=f"сид:{seed}", + origin=origin if text and seed != "brief" else f"сид:{seed}", text=prompt, ) self._ensure_worker(cast("int", conv.id)) diff --git a/src/beaver_gateway/frontends/telegram/drafts.py b/src/beaver_gateway/frontends/telegram/drafts.py index 7f7944b..7ffdb7e 100644 --- a/src/beaver_gateway/frontends/telegram/drafts.py +++ b/src/beaver_gateway/frontends/telegram/drafts.py @@ -15,7 +15,9 @@ import time import zlib from typing import TYPE_CHECKING -from aiogram.exceptions import TelegramAPIError +from aiogram.exceptions import TelegramAPIError, TelegramBadRequest + +from beaver_gateway.frontends.telegram.render import to_html if TYPE_CHECKING: from aiogram import Bot @@ -37,7 +39,7 @@ class Draft: thread_id: int | None, turn_id: str, interval: float = 0.7, - status: str = "⏳ думаю…", + status: str = "⏳ думаю", ) -> None: self._bot = bot self._chat_id = chat_id @@ -82,20 +84,27 @@ class Draft: async def _push(self) -> None: self._dirty = False self._last_sent = time.monotonic() + text = self._render() try: - await self._bot.send_message_draft( - chat_id=self._chat_id, - draft_id=self._draft_id, - message_thread_id=self._thread_id, - text=self._render(), - parse_mode=None, - ) + try: + await self._send(to_html(text), "HTML") + except TelegramBadRequest: + await self._send(text, None) except TelegramAPIError as exc: self._broken = True _log.warning( "draft to %s/%s stopped: %s", self._chat_id, self._thread_id, exc ) + async def _send(self, text: str, parse_mode: str | None) -> None: + await self._bot.send_message_draft( + chat_id=self._chat_id, + draft_id=self._draft_id, + message_thread_id=self._thread_id, + text=text, + parse_mode=parse_mode, + ) + def _render(self) -> str: tail = self.text[-_TAIL:] return f"{self.status}\n\n{tail}" if tail.strip() else self.status diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index 1c02023..108275e 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -26,6 +26,7 @@ from aiogram import Bot from aiogram.client.default import DefaultBotProperties from aiogram.exceptions import TelegramAPIError from aiogram.types import ( + BotCommand, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, @@ -53,13 +54,16 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram") FRONTEND = "telegram" _DONE = "done" -_COMMANDS = ("merge", "new", "chat", "status", "start", "help") -_HELP = ( - "General - мастер, топик - ветка. Создай топик и пиши в него.\n" - "/merge - слить ветку в мастер\n" - "/new [название] - новая ветка (в General - новый топик)\n" - "/chat <тема> - открыть глубокий чат в vault\n" - "/status - что с этим разговором" # noqa: RUF001 +_COMMAND_HELP = ( + ("merge", "слить ветку в мастер"), + ("new", "новая ветка: в General - новый топик, в топике - заново на нём"), + ("chat", "открыть глубокий чат в vault: /chat тема"), + ("status", "что с этим разговором"), # noqa: RUF001 + ("help", "команды"), +) +_COMMANDS = (*(c for c, _ in _COMMAND_HELP), "start") +_HELP = "General - мастер, любой другой топик - ветка.\n" + "\n".join( + f"/{c} - {d}" for c, d in _COMMAND_HELP ) @@ -171,6 +175,9 @@ class TelegramFrontend(Frontend): getattr(me, "has_topics_enabled", None), ) self._sweep_attachments() + await self.bot.set_my_commands( + [BotCommand(command=c, description=d) for c, d in _COMMAND_HELP] + ) try: async with asyncio.TaskGroup() as tg: tg.create_task(self.inbox.run()) @@ -305,25 +312,35 @@ class TelegramFrontend(Frontend): self._master_window = self._ext(topic.message_thread_id) return self._master_window - async def _master(self) -> Conversation: + async def _master(self, text: str | None = None) -> tuple[Conversation, bool]: + """The open master behind its window, spawning one when there is none. + + ``text`` rides with the seed of a fresh master; the second value says + whether it was consumed that way. + """ ext = await self._master_ext() conv = await self.conversations.find_bound(frontend=FRONTEND, external_id=ext) if conv is not None and conv.status == "open": - return conv + return conv, False masters = await self.conversations.find(kind="master", status="open", limit=1) if masters: await self.conversations.bind( masters[0], frontend=FRONTEND, external_id=ext ) - return masters[0] - return await self.conversations.spawn( - kind="master", seed="clean", origin=FRONTEND, binding=(FRONTEND, ext) + return masters[0], False + conv = await self.conversations.spawn( + kind="master", + seed="clean", + text=text, + origin=FRONTEND, + binding=(FRONTEND, ext), ) + return conv, text is not None async def _branch( self, thread_id: int, *, title: str | None, text: str | None ) -> Conversation: - master = await self._master() + master, _ = await self._master() return await self.conversations.spawn( kind="branch", seed="morning", @@ -343,10 +360,13 @@ class TelegramFrontend(Frontend): await self._on_callback(update.callback_query) async def _on_message(self, message: Message) -> None: - if message.forum_topic_created is not None and message.message_thread_id: - self._topic_names[message.message_thread_id] = ( - message.forum_topic_created.name - ) + created = message.forum_topic_created + if ( + created is not None + and message.message_thread_id + and not created.is_name_implicit + ): + self._topic_names[message.message_thread_id] = created.name if message.from_user is None or message.from_user.id != self.user_id: _log.warning( "ignoring message from %s", getattr(message.from_user, "id", None) @@ -359,11 +379,7 @@ class TelegramFrontend(Frontend): is_master = ( thread_id is None or self._ext(thread_id) == await self._master_ext() ) - if message.forum_topic_created is not None and thread_id is not None: - if not is_master and await self._live(thread_id) is None: - await self._branch( - thread_id, title=message.forum_topic_created.name, text=None - ) + if message.forum_topic_created is not None: return if message.forum_topic_edited is not None and thread_id is not None: if message.forum_topic_edited.name: @@ -382,11 +398,14 @@ class TelegramFrontend(Frontend): await self._command(command, args.strip(), message, thread_id) return if is_master: - conv = await self._master() + conv, consumed = await self._master(text) + if consumed: + return else: conv = await self._live(thread_id) if conv is None: - await self._branch(thread_id, title=self._title_from(text), text=text) + title = self._topic_names.get(thread_id) or self._title_from(text) + await self._branch(thread_id, title=title, text=text) return pending = self.conversations.pending_question(conv.external_id) if pending is not None and self.conversations.answer(pending[0], text): @@ -522,14 +541,14 @@ class TelegramFrontend(Frontend): is_master = ( thread_id is None or self._ext(thread_id) == await self._master_ext() ) - conv = await self._master() if is_master else await self._live(thread_id) + conv = (await self._master())[0] if is_master else await self._live(thread_id) if command == "status": return await self._status(conv) if command == "merge": if conv is None or conv.kind != "branch": return "сливать нечего: это не открытая ветка" self._spawn_task(self._merge(conv)) - return "🔀 сливаю в мастер…" + return "🔀 сливаю в мастер" if command == "new": if is_master or thread_id is None: child = await self.conversations.spawn( @@ -649,6 +668,10 @@ class TelegramFrontend(Frontend): key=f"{event.get('turn_id')}:error", ) case "reply": + if conv.kind == "master" and str( + event.get("item_origin") or "" + ).startswith("сид"): + return await self._on_reply(conv, event) case "say": await self._deliver( @@ -711,10 +734,10 @@ class TelegramFrontend(Frontend): if kind == "content_block_delta": delta = raw.get("delta") or {} if delta.get("type") == "text_delta": - draft.set_status("✍️ пишу…") + draft.set_status("✍️ пишу") draft.append(str(delta.get("text") or "")) elif delta.get("type") == "thinking_delta": - draft.set_status("🤔 думаю…") + draft.set_status("🤔 думаю") elif kind == "content_block_start": block = raw.get("content_block") or {} if block.get("type") == "tool_use": diff --git a/src/beaver_gateway/frontends/telegram/render.py b/src/beaver_gateway/frontends/telegram/render.py index 954cafc..52e59f5 100644 --- a/src/beaver_gateway/frontends/telegram/render.py +++ b/src/beaver_gateway/frontends/telegram/render.py @@ -20,25 +20,25 @@ _BULLET = re.compile(r"^(\s*)[-*]\s+", re.MULTILINE) _STRIKE = re.compile(r"~~(.+?)~~") _LABELS: dict[str, str] = { - "Read": "читаю vault…", - "Glob": "ищу файлы…", - "Grep": "ищу в vault…", - "Edit": "правлю файл…", - "Write": "пишу файл…", - "MultiEdit": "правлю файлы…", - "Bash": "выполняю команду…", - "WebSearch": "ищу в сети…", - "WebFetch": "читаю страницу…", - "Task": "запустил сабагента…", - "Agent": "запустил сабагента…", - "AskUserQuestion": "спрашиваю…", - "TodoWrite": "планирую…", - "Skill": "открываю скилл…", - "mcp__gateway__spawn": "открываю разговор…", - "mcp__gateway__read_conversation": "читаю разговор…", - "mcp__gateway__say": "говорю…", - "mcp__gateway__schedule": "ставлю напоминание…", - "mcp__gateway__inject": "передаю в другой разговор…", + "Read": "читаю vault", + "Glob": "ищу файлы", + "Grep": "ищу в vault", + "Edit": "правлю файл", + "Write": "пишу файл", + "MultiEdit": "правлю файлы", + "Bash": "выполняю команду", + "WebSearch": "ищу в сети", + "WebFetch": "читаю страницу", + "Task": "запустил сабагента", + "Agent": "запустил сабагента", + "AskUserQuestion": "спрашиваю", + "TodoWrite": "планирую", + "Skill": "открываю скилл", + "mcp__gateway__spawn": "открываю разговор", + "mcp__gateway__read_conversation": "читаю разговор", + "mcp__gateway__say": "говорю", + "mcp__gateway__schedule": "ставлю напоминание", + "mcp__gateway__inject": "передаю в другой разговор", } @@ -100,7 +100,7 @@ def status_label(name: str, tool_input: dict[str, Any] | None = None) -> str: parts = name.split("__", 2) server = parts[1] tool = parts[2] if len(parts) == 3 else "" - return f"{server}: {tool}…" if tool else f"{server}…" + return f"{server}: {tool}" if tool else server hint = "" if tool_input: for key in ("description", "command", "file_path", "pattern", "query"): @@ -108,4 +108,4 @@ def status_label(name: str, tool_input: dict[str, Any] | None = None) -> str: if isinstance(value, str) and value.strip(): hint = value.strip().splitlines()[0][:60] break - return f"{name} {hint}…".strip() if hint else f"{name}…" + return f"{name} {hint}" if hint else name diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 335036a..eaa49a2 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -234,7 +234,8 @@ async def stack() -> Stack: async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None: stack.bot.message("hi") - reply = await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + reply = await stack.until(lambda: stack.sent_with("hi"), what="reply") + assert reply["text"].startswith("ok:[сид: clean] master") assert reply["thread"] == 901 assert reply["parse_mode"] == "HTML" assert stack.bot.topics == ["🦫 General"] @@ -244,7 +245,7 @@ async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None: assert master is not None and master.kind == "master" assert stack.bot.drafts and stack.bot.drafts[0]["chat_id"] == USER rows = await stack.world.conversations.queue.recent(master.id) - assert {r.origin for r in rows} == {"сид:clean", "telegram"} + assert [r.origin for r in rows] == ["telegram"] async def test_new_topic_becomes_morning_branch_and_replies_in_thread( @@ -252,8 +253,9 @@ async def test_new_topic_becomes_morning_branch_and_replies_in_thread( ) -> None: stack.bot.topic_created("план", 7) stack.bot.message("hello topic", thread=7) - reply = await stack.until(lambda: stack.sent_with("ok:hello topic"), what="reply") + reply = await stack.until(lambda: stack.sent_with("hello topic"), what="reply") assert reply["thread"] == 7 + assert len([m for m in stack.bot.sent if m["thread"] == 7]) == 1 branch = await stack.world.conversations.find_bound( frontend="telegram", external_id=f"{USER}/7" ) @@ -266,6 +268,7 @@ async def test_new_topic_becomes_morning_branch_and_replies_in_thread( prompts = [p for c in ScriptedClient.instances for p in c.prompts] seed = next(p for p in prompts if p.startswith("[сид: morning] branch «план»")) assert "Хендаут не приехал." in seed + assert seed.endswith("hello topic") assert stack.bot.drafts[-1]["message_thread_id"] == 7 @@ -317,7 +320,7 @@ async def test_message_into_merged_branch_rebinds_the_topic(stack: Stack) -> Non async def test_reply_from_another_window_is_mirrored_with_marker(stack: Stack) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") master = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) @@ -331,7 +334,7 @@ async def test_reply_from_another_window_is_mirrored_with_marker(stack: Stack) - async def test_say_is_delivered_and_inject_turns_are_silent(stack: Stack) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") master = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) @@ -339,7 +342,7 @@ async def test_say_is_delivered_and_inject_turns_are_silent(stack: Stack) -> Non await stack.world.conversations.inject( master, "cron tick", urgency="urgent", origin="крон" ) - await stack.world.settle(master, 3) + await stack.world.settle(master, 2) await asyncio.sleep(0.2) assert len(stack.bot.sent) == before await stack.world.conversations.say(master, "psst") @@ -348,7 +351,7 @@ async def test_say_is_delivered_and_inject_turns_are_silent(stack: Stack) -> Non async def test_question_becomes_buttons_and_callback_answers(stack: Stack) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") master = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) @@ -393,7 +396,7 @@ async def test_question_timeout_renders_text_and_free_text_answers( stack: Stack, ) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") master = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) @@ -455,16 +458,16 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None: async def test_master_topic_survives_rotation(stack: Stack) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") master = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) stack.bot.message("into general", thread=901) - await stack.until(lambda: stack.sent_with("ok:into general"), what="reply") + await stack.until(lambda: stack.sent_with("into general"), what="reply") assert (await stack.world.conversations.find(kind="branch")) == [] await stack.world.conversations.set_status(master, "closed") stack.bot.message("after rotation", thread=901) - await stack.until(lambda: stack.sent_with("ok:after rotation"), what="reply") + await stack.until(lambda: stack.sent_with("after rotation"), what="reply") fresh = await stack.world.conversations.find_bound( frontend="telegram", external_id=GENERAL ) @@ -475,7 +478,7 @@ async def test_master_topic_survives_rotation(stack: Stack) -> None: async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> None: stack.bot.message("hi") - await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") + await stack.until(lambda: stack.sent_with("hi"), what="reply") async with stack.world.db.session() as session: rows = list((await session.exec(select(TelegramUpdate))).all()) assert [r.update_id for r in rows] == [1000] @@ -498,7 +501,7 @@ async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> Non ) await session.commit() await stack.until( - lambda: stack.sent_with("ok:replayed"), timeout=8, what="replayed reply" + lambda: stack.sent_with("replayed"), timeout=8, what="replayed reply" ) stack.bot.message("stranger", uid=1) await asyncio.sleep(0.3) @@ -509,7 +512,7 @@ async def test_outbox_retries_and_falls_back_to_plain(stack: Stack) -> None: stack.bot.fail_sends = 2 stack.bot.reject_html = True stack.bot.message("**bold**") - reply = await stack.until(lambda: stack.sent_with("ok:**bold**"), what="reply") + reply = await stack.until(lambda: stack.sent_with("**bold**"), what="reply") assert reply["parse_mode"] is None async with stack.world.db.session() as session: rows = list((await session.exec(select(Delivery))).all()) @@ -548,8 +551,6 @@ def test_render_helpers() -> None: ) parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40) assert parts == ["абв\n\n" + "г" * 30, "д" * 30] - assert status_label("Read", {"file_path": "/x"}) == "читаю vault…" - assert ( - status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts…" - ) - assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la…" + assert status_label("Read", {"file_path": "/x"}) == "читаю vault" + assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts" + assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"