feat(telegram,core): master topic instead of general in private chats

This commit is contained in:
hh
2026-08-28 18:12:58 +02:00
parent 5177392f46
commit 9663fbf956
3 changed files with 93 additions and 18 deletions
+22
View File
@@ -345,6 +345,28 @@ class Conversations:
) )
return result.first() 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( async def set_flags(
self, conv: Conversation, flags: dict[str, Any] self, conv: Conversation, flags: dict[str, Any]
) -> Conversation: ) -> Conversation:
@@ -1,6 +1,8 @@
"""``TelegramFrontend`` - the private chat with the bot as the window (§3.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 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 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 topic. Replies stream as drafts and land through the outbox; turns that
@@ -112,6 +114,7 @@ class TelegramFrontend(Frontend):
chat_id: int | None = None, chat_id: int | None = None,
attachments: Attachments = EPHEMERAL, attachments: Attachments = EPHEMERAL,
draft_interval: float = 0.7, draft_interval: float = 0.7,
master_topic: str = "🦫 General",
queued_reaction: str = "👀", queued_reaction: str = "👀",
poll_timeout: int = 30, poll_timeout: int = 30,
outbox_backoff: float = 2.0, outbox_backoff: float = 2.0,
@@ -122,6 +125,7 @@ class TelegramFrontend(Frontend):
self.master_agent = master_agent self.master_agent = master_agent
self.branch_agent = branch_agent self.branch_agent = branch_agent
self.attachments = attachments self.attachments = attachments
self.master_topic = master_topic
self.draft_interval = draft_interval self.draft_interval = draft_interval
self.queued_reaction = queued_reaction self.queued_reaction = queued_reaction
self.poll_timeout = poll_timeout self.poll_timeout = poll_timeout
@@ -131,6 +135,7 @@ class TelegramFrontend(Frontend):
self._inbox: Inbox | None = None self._inbox: Inbox | None = None
self._outbox: Outbox | None = None self._outbox: Outbox | None = None
self._targets: dict[str, tuple[int, int | None] | None] = {} self._targets: dict[str, tuple[int, int | None] | None] = {}
self._master_window: str | None = None
self._topic_names: dict[int, str] = {} self._topic_names: dict[int, str] = {}
self._drafts: dict[str, Draft] = {} self._drafts: dict[str, Draft] = {}
self._asks: dict[str, _Ask] = {} self._asks: dict[str, _Ask] = {}
@@ -179,7 +184,7 @@ class TelegramFrontend(Frontend):
async def materialize(self, conv: Conversation) -> ConversationBinding | None: async def materialize(self, conv: Conversation) -> ConversationBinding | None:
if conv.kind == "master": if conv.kind == "master":
return await self.conversations.bind( 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": if conv.kind != "branch":
return None return None
@@ -278,8 +283,30 @@ class TelegramFrontend(Frontend):
dedupe_key=key, 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: 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) conv = await self.conversations.find_bound(frontend=FRONTEND, external_id=ext)
if conv is not None and conv.status == "open": if conv is not None and conv.status == "open":
return conv return conv
@@ -329,8 +356,11 @@ class TelegramFrontend(Frontend):
return return
in_topic = message.is_topic_message or message.forum_topic_created is not None 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 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 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( await self._branch(
thread_id, title=message.forum_topic_created.name, text=None thread_id, title=message.forum_topic_created.name, text=None
) )
@@ -351,7 +381,7 @@ class TelegramFrontend(Frontend):
if command in _COMMANDS: if command in _COMMANDS:
await self._command(command, args.strip(), message, thread_id) await self._command(command, args.strip(), message, thread_id)
return return
if thread_id is None: if is_master:
conv = await self._master() conv = await self._master()
else: else:
conv = await self._live(thread_id) 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: async def _run_command(self, command: str, args: str, thread_id: int | None) -> str:
if command in ("start", "help"): if command in ("start", "help"):
return _HELP return _HELP
conv = ( is_master = (
await self._master() if thread_id is None else await self._live(thread_id) 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": if command == "status":
return await self._status(conv) return await self._status(conv)
if command == "merge": if command == "merge":
@@ -500,7 +531,7 @@ class TelegramFrontend(Frontend):
self._spawn_task(self._merge(conv)) self._spawn_task(self._merge(conv))
return "🔀 сливаю в мастер…" return "🔀 сливаю в мастер…"
if command == "new": if command == "new":
if thread_id is None: if is_master or thread_id is None:
child = await self.conversations.spawn( child = await self.conversations.spawn(
kind="branch", kind="branch",
seed="morning", seed="morning",
+32 -10
View File
@@ -20,6 +20,7 @@ from sqlmodel import select
from test_conversations import ScriptedClient, StubFrontend, World from test_conversations import ScriptedClient, StubFrontend, World
USER = 42 USER = 42
GENERAL = f"{USER}/901"
class FakeBot: class FakeBot:
@@ -234,10 +235,11 @@ async def stack() -> Stack:
async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None: async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None:
stack.bot.message("hi") stack.bot.message("hi")
reply = await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") 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 reply["parse_mode"] == "HTML"
assert stack.bot.topics == ["🦫 General"]
master = await stack.world.conversations.find_bound( 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 master is not None and master.kind == "master"
assert stack.bot.drafts and stack.bot.drafts[0]["chat_id"] == USER 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 is not None
assert branch.kind == "branch" and branch.title == "план" assert branch.kind == "branch" and branch.title == "план"
master = await stack.world.conversations.find_bound( master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=str(USER) frontend="telegram", external_id=GENERAL
) )
assert branch.parent_id == master.id assert branch.parent_id == master.id
prompts = [p for c in ScriptedClient.instances for p in c.prompts] 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") stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply")
master = await stack.world.conversations.find_bound( 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.world.conversations.post(master, "from panel", origin="user")
await stack.until(lambda: stack.sent_with("ok:from panel"), what="mirrored reply") 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") stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply")
master = await stack.world.conversations.find_bound( master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=str(USER) frontend="telegram", external_id=GENERAL
) )
before = len(stack.bot.sent) before = len(stack.bot.sent)
await stack.world.conversations.inject( 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") stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply")
master = await stack.world.conversations.find_bound( master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=str(USER) frontend="telegram", external_id=GENERAL
) )
payload = { payload = {
"questions": [ "questions": [
@@ -393,7 +395,7 @@ async def test_question_timeout_renders_text_and_free_text_answers(
stack.bot.message("hi") stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("ok:hi"), what="reply") await stack.until(lambda: stack.sent_with("ok:hi"), what="reply")
master = await stack.world.conversations.find_bound( 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 stack.world.conversations._question_timeout = 0.3 # noqa: SLF001
payload = {"questions": [{"header": "Q", "question": "Сколько?", "options": []}]} 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" assert branch.status == "merged"
stack.bot.message("/new отчёт") stack.bot.message("/new отчёт")
await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic") 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( 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 child is not None and child.title == "отчёт"
assert await stack.tg.mark_topic(child) 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: async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> None: