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()
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:
@@ -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",
+32 -10
View File
@@ -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: