diff --git a/src/beaver_gateway/core/conversations.py b/src/beaver_gateway/core/conversations.py index 27d793a..8c5ee44 100644 --- a/src/beaver_gateway/core/conversations.py +++ b/src/beaver_gateway/core/conversations.py @@ -345,6 +345,28 @@ class Conversations: ) return result.first() + async def last_binding( + self, *, frontend: str, kind: str + ) -> ConversationBinding | None: + """The window ``frontend`` last used for a conversation of ``kind``. + + A frontend whose window for the master outlives the master itself + (the Telegram General topic) finds it here after a rotation. + """ + async with self._db.session() as session: + result = await session.exec( + select(ConversationBinding) + .join( + Conversation, + col(Conversation.id) == col(ConversationBinding.conversation_id), + ) + .where( + ConversationBinding.frontend == frontend, Conversation.kind == kind + ) + .order_by(col(ConversationBinding.id).desc()) + ) + return result.first() + async def set_flags( self, conv: Conversation, flags: dict[str, Any] ) -> Conversation: diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index 37c896c..1c02023 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -1,6 +1,8 @@ """``TelegramFrontend`` - the private chat with the bot as the window (§3.8). -General is the master, a topic is a branch. The user makes a topic and the +A private chat with topics has no General: the gateway makes one topic for +the master (``master_topic``) and rebinds it to every new master; any other +topic is a branch. The user makes a topic and the first message in it spawns the branch (``seed=morning``); a message into a topic whose branch is merged or closed spawns a new branch on the same topic. Replies stream as drafts and land through the outbox; turns that @@ -112,6 +114,7 @@ class TelegramFrontend(Frontend): chat_id: int | None = None, attachments: Attachments = EPHEMERAL, draft_interval: float = 0.7, + master_topic: str = "🦫 General", queued_reaction: str = "👀", poll_timeout: int = 30, outbox_backoff: float = 2.0, @@ -122,6 +125,7 @@ class TelegramFrontend(Frontend): self.master_agent = master_agent self.branch_agent = branch_agent self.attachments = attachments + self.master_topic = master_topic self.draft_interval = draft_interval self.queued_reaction = queued_reaction self.poll_timeout = poll_timeout @@ -131,6 +135,7 @@ class TelegramFrontend(Frontend): self._inbox: Inbox | None = None self._outbox: Outbox | None = None self._targets: dict[str, tuple[int, int | None] | None] = {} + self._master_window: str | None = None self._topic_names: dict[int, str] = {} self._drafts: dict[str, Draft] = {} self._asks: dict[str, _Ask] = {} @@ -179,7 +184,7 @@ class TelegramFrontend(Frontend): async def materialize(self, conv: Conversation) -> ConversationBinding | None: if conv.kind == "master": return await self.conversations.bind( - conv, frontend=FRONTEND, external_id=self._ext(None) + conv, frontend=FRONTEND, external_id=await self._master_ext() ) if conv.kind != "branch": return None @@ -278,8 +283,30 @@ class TelegramFrontend(Frontend): dedupe_key=key, ) + async def _master_ext(self) -> str: + """The master's window. + + General in a forum group, our own topic in a private chat (Telegram + has no General there). Created once, then found through whatever + master used it last. + """ + if self._master_window is not None: + return self._master_window + last = await self.conversations.last_binding(frontend=FRONTEND, kind="master") + if last is not None: + self._master_window = last.external_id + elif self.chat_id < 0: + self._master_window = self._ext(None) + else: + topic = await self.bot.create_forum_topic( + self.chat_id, name=self.master_topic[:128] + ) + self._topic_names[topic.message_thread_id] = topic.name + self._master_window = self._ext(topic.message_thread_id) + return self._master_window + async def _master(self) -> Conversation: - ext = self._ext(None) + 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 @@ -329,8 +356,11 @@ class TelegramFrontend(Frontend): return in_topic = message.is_topic_message or message.forum_topic_created is not None thread_id = message.message_thread_id if in_topic else None + 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 await self._live(thread_id) is 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 ) @@ -351,7 +381,7 @@ class TelegramFrontend(Frontend): if command in _COMMANDS: await self._command(command, args.strip(), message, thread_id) return - if thread_id is None: + if is_master: conv = await self._master() else: conv = await self._live(thread_id) @@ -489,9 +519,10 @@ class TelegramFrontend(Frontend): async def _run_command(self, command: str, args: str, thread_id: int | None) -> str: if command in ("start", "help"): return _HELP - conv = ( - await self._master() if thread_id is None else await self._live(thread_id) + 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) if command == "status": return await self._status(conv) if command == "merge": @@ -500,7 +531,7 @@ class TelegramFrontend(Frontend): self._spawn_task(self._merge(conv)) return "🔀 сливаю в мастер…" if command == "new": - if thread_id is None: + if is_master or thread_id is None: child = await self.conversations.spawn( kind="branch", seed="morning", diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 0adef03..335036a 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -20,6 +20,7 @@ from sqlmodel import select from test_conversations import ScriptedClient, StubFrontend, World USER = 42 +GENERAL = f"{USER}/901" class FakeBot: @@ -234,10 +235,11 @@ 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") - assert reply["thread"] is None + assert reply["thread"] == 901 assert reply["parse_mode"] == "HTML" + assert stack.bot.topics == ["🦫 General"] master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) assert master is not None and master.kind == "master" assert stack.bot.drafts and stack.bot.drafts[0]["chat_id"] == USER @@ -258,7 +260,7 @@ async def test_new_topic_becomes_morning_branch_and_replies_in_thread( assert branch is not None assert branch.kind == "branch" and branch.title == "план" master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) assert branch.parent_id == master.id prompts = [p for c in ScriptedClient.instances for p in c.prompts] @@ -317,7 +319,7 @@ async def test_reply_from_another_window_is_mirrored_with_marker(stack: Stack) - stack.bot.message("hi") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) await stack.world.conversations.post(master, "from panel", origin="user") await stack.until(lambda: stack.sent_with("ok:from panel"), what="mirrored reply") @@ -331,7 +333,7 @@ async def test_say_is_delivered_and_inject_turns_are_silent(stack: Stack) -> Non stack.bot.message("hi") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) before = len(stack.bot.sent) await stack.world.conversations.inject( @@ -348,7 +350,7 @@ async def test_question_becomes_buttons_and_callback_answers(stack: Stack) -> No stack.bot.message("hi") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) payload = { "questions": [ @@ -393,7 +395,7 @@ async def test_question_timeout_renders_text_and_free_text_answers( stack.bot.message("hi") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") master = await stack.world.conversations.find_bound( - frontend="telegram", external_id=str(USER) + frontend="telegram", external_id=GENERAL ) stack.world.conversations._question_timeout = 0.3 # noqa: SLF001 payload = {"questions": [{"header": "Q", "question": "Сколько?", "options": []}]} @@ -442,13 +444,33 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None: assert branch.status == "merged" stack.bot.message("/new отчёт") await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic") - assert stack.bot.topics == ["отчёт"] + assert stack.bot.topics == ["🦫 General", "отчёт"] child = await stack.world.conversations.find_bound( - frontend="telegram", external_id=f"{USER}/901" + frontend="telegram", external_id=f"{USER}/902" ) assert child is not None and child.title == "отчёт" assert await stack.tg.mark_topic(child) - assert stack.bot.topics[-1] == "edit:901:✅ отчёт" + assert stack.bot.topics[-1] == "edit:902:✅ отчёт" + + +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") + 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") + 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") + fresh = await stack.world.conversations.find_bound( + frontend="telegram", external_id=GENERAL + ) + assert fresh.kind == "master" and fresh.id != master.id + assert stack.bot.topics == ["🦫 General"] + assert (await stack.world.conversations.find(kind="branch")) == [] async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> None: