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()
|
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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user