feat(telegram,core): master topic instead of general in private chats
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user