diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index 0e6e6bf..c8e02ad 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -19,8 +19,10 @@ import logging import tempfile import time from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast +from zoneinfo import ZoneInfo from aiogram import Bot from aiogram.client.default import DefaultBotProperties @@ -71,14 +73,21 @@ _HELP = "General - мастер, любой другой топик - ветка class Attachments: """Where files from Telegram go. - ``ephemeral`` - under the gateway's data dir, swept after ``keep_days``; - ``vault`` - into a directory the agent owns. Pulling what is worth - keeping into the vault is the agent's job either way. + Files land in ``/YYYY-MM-DD/-`` (the day in ``tz``) + and whatever is older than ``keep_days`` is swept; ``None`` never + sweeps. ``ephemeral`` - under the gateway's data dir; ``vault`` - + ``dir`` is an inbox inside the agent's zone: the agent moves keepers + next to the note, the rest is swept after ``keep_days``. """ mode: Literal["ephemeral", "vault"] = "ephemeral" dir: Path | None = None - keep_days: int = 7 + keep_days: int | None = 7 + tz: str = "UTC" + + def day(self, now: float | None = None) -> str: + stamp = datetime.fromtimestamp(now or time.time(), tz=ZoneInfo(self.tz)) + return stamp.date().isoformat() @property def root(self) -> Path: @@ -174,7 +183,6 @@ class TelegramFrontend(Frontend): self.chat_id, getattr(me, "has_topics_enabled", None), ) - self._sweep_attachments() await self.bot.set_my_commands( [BotCommand(command=c, description=d) for c, d in _COMMAND_HELP] ) @@ -183,6 +191,7 @@ class TelegramFrontend(Frontend): tg.create_task(self.inbox.run()) tg.create_task(self.outbox.run()) tg.create_task(self._events()) + tg.create_task(self._sweep_loop()) finally: for draft in list(self._drafts.values()): await draft.stop() @@ -373,7 +382,11 @@ class TelegramFrontend(Frontend): return if message.chat.id != self.chat_id: 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 + or message.forum_topic_edited 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() @@ -382,10 +395,12 @@ class TelegramFrontend(Frontend): return if message.forum_topic_edited is not None and thread_id is not None: if message.forum_topic_edited.name: - self._topic_names[thread_id] = message.forum_topic_edited.name + await self._on_topic_renamed( + thread_id, message.forum_topic_edited.name, is_master=is_master + ) return text = (message.text or message.caption or "").strip() - attachment = await self._save_attachment(message, thread_id) + attachment = await self._save_attachment(message) if attachment: text = f"{text}\n\n{attachment}".strip() if not text: @@ -414,6 +429,14 @@ class TelegramFrontend(Frontend): if conv.running_turn or conv.pending_question: await self._react(message, item.id) + async def _on_topic_renamed( + self, thread_id: int, name: str, *, is_master: bool + ) -> None: + self._topic_names[thread_id] = name + conv = None if is_master else await self._live(thread_id) + if conv is not None: + await self.conversations.set_title(conv, name) + async def _live(self, thread_id: int) -> Conversation | None: conv = await self.conversations.find_bound( frontend=FRONTEND, external_id=self._ext(thread_id) @@ -446,13 +469,15 @@ class TelegramFrontend(Frontend): with contextlib.suppress(TelegramAPIError): await self.bot.set_message_reaction(target[0], target[1], reaction=[]) - async def _save_attachment( - self, message: Message, thread_id: int | None - ) -> str | None: + async def _save_attachment(self, message: Message) -> str | None: file_id: str | None = None name: str | None = None kind = "" size: int | None = None + if message.sticker: + return "[вложение: стикер - не поддерживается]" + if message.animation: + return "[вложение: анимация - не поддерживается]" if message.photo: photo = message.photo[-1] file_id, kind, size = photo.file_id, "фото", photo.file_size @@ -496,12 +521,13 @@ class TelegramFrontend(Frontend): f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - " "больше 20 МБ, Telegram не отдаёт ботам]" ) - folder = self.attachments.root / (self._ext(thread_id).replace("/", "_")) + now = time.time() + folder = self.attachments.root / self.attachments.day(now) folder.mkdir(parents=True, exist_ok=True) with contextlib.suppress(OSError): self.attachments.root.chmod(0o755) folder.chmod(0o755) - path = folder / f"{int(time.time())}-{Path(name).name}" + path = folder / f"{int(now)}-{Path(name).name}" try: await self.bot.download(file_id, destination=path) path.chmod(0o644) @@ -509,19 +535,33 @@ class TelegramFrontend(Frontend): _log.warning("attachment download failed: %s", exc) return f"[вложение: {kind} {name} - не скачалось: {exc}]" shown = f", {size // 1024} КБ" if size else "" - return f"[вложение: {kind} {path}{shown}]" + keep = self.attachments.keep_days + if keep is None: + return f"[вложение: {kind} {path}{shown}]" + return ( + f"[вложение: {kind} {path}{shown}; хранится {keep} дн., " + "перенеси в vault, если нужно]" + ) + + async def _sweep_loop(self, interval: float = 6 * 3600) -> None: + while True: + self._sweep_attachments() + await asyncio.sleep(interval) def _sweep_attachments(self) -> None: - if self.attachments.mode != "ephemeral": - return + keep = self.attachments.keep_days root = self.attachments.root - if not root.exists(): + if keep is None or not root.exists(): return - cutoff = time.time() - self.attachments.keep_days * 86400 + cutoff = time.time() - keep * 86400 for path in root.rglob("*"): with contextlib.suppress(OSError): if path.is_file() and path.stat().st_mtime < cutoff: path.unlink() + for folder in root.iterdir(): + with contextlib.suppress(OSError): + if folder.is_dir() and not any(folder.iterdir()): + folder.rmdir() # ---- commands -------------------------------------------------------- @@ -634,7 +674,10 @@ class TelegramFrontend(Frontend): if kind == "conversation.bound": self._targets.pop(str(event.get("conversation_id")), None) return - if kind in ("delivery.sent", "delivery.failed", "conversation.created"): + if kind == "delivery.failed": + await self._on_delivery_failed(event) + return + if kind in ("delivery.sent", "conversation.created"): return key = event.get("conversation_id") if not isinstance(key, str): @@ -661,10 +704,13 @@ class TelegramFrontend(Frontend): draft = self._drafts.get(key) if draft is not None: await draft.stop() - if event.get("stop") == "error" and event.get("origin") == "user": + if event.get("stop") == "error": + origin = event.get("origin") await self._deliver( conv, - "⚠️ тёрн упал, смотри логи gateway", + "⚠️ тёрн упал, смотри логи gateway" + if origin == "user" + else f"⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway", turn_id=event.get("turn_id"), key=f"{event.get('turn_id')}:error", ) @@ -687,6 +733,38 @@ class TelegramFrontend(Frontend): case "conversation.merged": await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged") + async def _on_delivery_failed(self, event: Event) -> None: + row = event.get("conversation_row") + _log.error( + "delivery to %s/%s (conversation row %s) failed: %s", + event.get("chat_id"), + event.get("thread_id"), + row, + event.get("error"), + ) + if not event.get("gone") or not isinstance(row, int): + return + conv = await self.conversations.get_row(row) + if conv is None: + return + bound = next( + ( + b + for b in await self.conversations.bindings(conv) + if b.frontend == FRONTEND and b.visible + ), + None, + ) + if bound is None: + return + _log.warning( + "window %s of %s is gone; unbinding", bound.external_id, conv.external_id + ) + await self.conversations.bind( + conv, frontend=FRONTEND, external_id=bound.external_id, visible=False + ) + self._targets.pop(conv.external_id, None) + async def _on_reply(self, conv: Conversation, event: Event) -> None: turn_id = str(event.get("turn_id") or "") origin = str(event.get("item_origin") or "") @@ -789,6 +867,7 @@ class TelegramFrontend(Frontend): ask = self._asks.get(question_id) if ask is None or not qi_raw.isdigit(): await self._callback_reply(query, "вопрос уже закрыт") + await self._strip_keyboard(query) return qi = int(qi_raw) question = ask.questions[qi] @@ -831,6 +910,17 @@ class TelegramFrontend(Frontend): if not self.conversations.answer(question_id, answer): await self._edit_asks(ask, "⌛ время вышло - ответь текстом") + async def _strip_keyboard(self, query: CallbackQuery) -> None: + message = query.message if isinstance(query.message, Message) else None + if message is None: + return + with contextlib.suppress(TelegramAPIError): + await self.bot.edit_message_reply_markup( + chat_id=message.chat.id, + message_id=message.message_id, + reply_markup=None, + ) + async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None: with contextlib.suppress(TelegramAPIError): await self.bot.answer_callback_query(query.id, text=text) diff --git a/src/beaver_gateway/frontends/telegram/outbox.py b/src/beaver_gateway/frontends/telegram/outbox.py index d95cb04..46837a7 100644 --- a/src/beaver_gateway/frontends/telegram/outbox.py +++ b/src/beaver_gateway/frontends/telegram/outbox.py @@ -143,7 +143,7 @@ class Outbox: if "parse" in text and not row.plain: await self._retry(row, str(exc), delay=0.0, plain=True) elif any(marker in text for marker in _GONE): - await self._fail(row, str(exc)) + await self._fail(row, str(exc), gone=True) else: await self._fail(row, str(exc)) except (TelegramNotFound, TelegramForbiddenError) as exc: @@ -180,7 +180,7 @@ class Outbox: if delay < 5.0: self._wake.set() - async def _fail(self, row: Delivery, error: str) -> None: + async def _fail(self, row: Delivery, error: str, *, gone: bool = False) -> None: _log.error( "delivery #%s to %s/%s given up: %s", row.id, @@ -196,6 +196,7 @@ class Outbox: chat_id=row.chat_id, thread_id=row.thread_id, error=error, + gone=gone, ) async def _mark( diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 3e8d99b..8ba0323 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -13,8 +13,14 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny from beaver_gateway.core.registry import McpRegistry from beaver_gateway.core.transcript import build_entries from beaver_gateway.frontends.base import GatewayRuntime -from beaver_gateway.frontends.telegram import TelegramFrontend -from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html +from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend +from beaver_gateway.frontends.telegram.render import ( + LIMIT, + chunks, + status_label, + to_html, + to_html_tail, +) from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate from sqlmodel import select from test_conversations import ScriptedClient, StubFrontend, World @@ -33,6 +39,7 @@ class FakeBot: self.reactions: list[tuple[int, list[Any]]] = [] self.fail_sends = 0 self.reject_html = False + self.gone_threads: set[int] = set() self.order: list[tuple[str, int | None]] = [] self._message_id = 100 self.session = SimpleNamespace(close=self._close) @@ -64,6 +71,11 @@ class FakeBot: method=SendMessage(chat_id=0, text="x"), message="Bad Request: can't parse entities", ) + if message_thread_id in self.gone_threads: + raise TelegramBadRequest( + method=SendMessage(chat_id=0, text="x"), + message="Bad Request: message thread not found", + ) self._message_id += 1 self.order.append(("message", message_thread_id)) self.sent.append( @@ -86,7 +98,8 @@ class FakeBot: async def create_forum_topic(self, chat_id, name, **_: Any) -> Any: self.topics.append(name) - return SimpleNamespace(message_thread_id=900 + len(self.topics), name=name) + created = sum(1 for t in self.topics if not t.startswith("edit:")) + return SimpleNamespace(message_thread_id=900 + created, name=name) async def edit_forum_topic( self, chat_id, message_thread_id, name=None, **_: Any @@ -148,6 +161,33 @@ class FakeBot: } ) + def topic_edited(self, name: str, thread: int) -> None: + self.push( + { + "message": { + "message_id": 10 + len(self.updates), + "date": 1700000000, + "chat": {"id": USER, "type": "private"}, + "from": {"id": USER, "is_bot": False, "first_name": "h"}, + "message_thread_id": thread, + "is_topic_message": True, + "forum_topic_edited": {"name": name}, + } + } + ) + + def media(self, body: dict[str, Any], *, thread: int | None = None) -> None: + message: dict[str, Any] = { + "message_id": 10 + len(self.updates), + "date": 1700000000, + "chat": {"id": USER, "type": "private"}, + "from": {"id": USER, "is_bot": False, "first_name": "h"}, + **body, + } + if thread is not None: + message |= {"message_thread_id": thread, "is_topic_message": True} + self.push({"message": message}) + def callback(self, data: str, message_id: int) -> None: self.push( { @@ -455,9 +495,10 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None: frontend="telegram", external_id=f"{USER}/11" ) assert branch.status == "merged" + assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"] stack.bot.message("/new отчёт") await stack.until(lambda: stack.sent_with("в новом топике"), what="new topic") - assert stack.bot.topics == ["🦫 General", "отчёт"] + assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"] child = await stack.world.conversations.find_bound( frontend="telegram", external_id=f"{USER}/902" ) @@ -583,18 +624,112 @@ def test_render_helpers() -> None: == "жирно и code <b>" ) assert to_html("# Заголовок\n- пункт") == "Заголовок\n• пункт" - assert to_html("```py\nx = 1\n```") == "
x = 1
" + assert to_html("```\nx < 1 & y\n```") == "
x < 1 & y
" + assert ( + to_html("```py\nunterminated\n") + == '
unterminated
' + ) assert ( to_html("[док](https://a.b/c?x=1&y=2)") == 'док' ) + assert ( + to_html("[tg](tg://user?id=1) https://x.y/a_b_c") + == 'tg https://x.y/a_b_c' + ) + assert to_html("![alt](https://x.y/i.png) ![](https://x.y/j.png)") == ( + 'alt https://x.y/j.png' + ) + assert to_html("~~нет~~ _к_ *к* __ж__") == "нет к к ж" + assert to_html("---\n***\n___") == "———\n———\n———" + assert to_html("`**raw** [[x]]`") == "**raw** [[x]]" parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40) assert parts == ["абв\n\n" + "г" * 30, "д" * 30] + assert chunks(" \n ") == [] assert status_label("Read", {"file_path": "/x"}) == "читаю vault" assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts" assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la" +def test_render_wikilinks() -> None: + assert to_html("[[Note]]") == "Note" + assert to_html("[[Note|alias]]") == "alias" + assert to_html("[[Note#heading]]") == "Note › heading" + assert to_html("[[A & B|**x**]]") == "**x**" + + +def test_render_blocks() -> None: + assert ( + to_html("> цитата **жирно**\n> вторая\n\nтекст") + == "
цитата жирно\nвторая
\n\nтекст" + ) + assert ( + to_html("1. один\n2) два\n - [ ] нет\n- [x] да") + == "1. один\n2. два\n ☐ нет\n☑ да" + ) + assert ( + to_html("| a | bb |\n|---|:--:|\n| ccc | d |\n| e |") + == "
a   | bb\nccc | d\ne
" + ) + assert ( + to_html("```python\nprint(1)\n```") + == '
print(1)
' + ) + assert ( + to_html("||секрет|| и `||нет||`") + == "секрет и ||нет||" + ) + + +def test_chunks_split_fence_into_valid_fences() -> None: + body = "\n".join(f"x = {i} & {i}" for i in range(400)) + text = f"до\n```python\n{body}\n```\nпосле" + parts = chunks(text, limit=1500) + assert len(parts) > 2 + assert parts[0] == "до" + assert all(p.startswith("```python\n") and p.endswith("```") for p in parts[1:-1]) + assert parts[-1].endswith("```\nпосле") + fences = [p.removesuffix("\nпосле") for p in parts if p.startswith("```")] + inner = [p.removeprefix("```python\n").removesuffix("\n```") for p in fences] + assert "\n".join(inner) == body + for p in parts: + rendered = to_html(p) + assert 0 < len(rendered) <= 1500 + for p in fences: + assert to_html(p).startswith('
')
+        assert to_html(p).endswith("
") + + +def test_chunks_split_blockquote_and_stay_under_limit() -> None: + text = "\n".join(f"> строка {i} **ж** <" for i in range(300)) + parts = chunks(text, limit=1000) + assert len(parts) > 1 + for p in parts: + rendered = to_html(p) + assert len(rendered) <= 1000 + assert rendered.startswith("
") and rendered.endswith( + "
" + ) + assert "".join(parts).count("строка") == 300 + long_line = "слово " * 2000 + parts = chunks(long_line, limit=LIMIT) + assert all(0 < len(to_html(p)) <= LIMIT for p in parts) + assert " ".join(parts).split() == long_line.split() + + +def test_to_html_tail_inside_fence() -> None: + full = ( + "intro\n```py\n" + "\n".join(f"code {i} <" for i in range(200)) + "\n```\nafter" + ) + rendered = to_html_tail(full, 100) + assert rendered.startswith('
code ')
+    assert rendered.endswith("
\nafter") + assert "```" not in rendered + assert to_html_tail("a\n> b\n> c", 3) == "
c
" + assert to_html_tail("**x** y", 100) == "x y" + assert to_html_tail("x" * 50, 10) == "x" * 10 + + async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None: stack.bot.push( { @@ -614,3 +749,171 @@ async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) preview = rows[0].payload["message"]["link_preview_options"] assert preview["url"] == "https://x.y" assert preview.get("is_disabled") is None + + +def _photo(caption: str) -> dict[str, Any]: + return { + "caption": caption, + "photo": [ + { + "file_id": "f1", + "file_unique_id": "u1", + "width": 1, + "height": 1, + "file_size": 2048, + } + ], + } + + +async def test_attachments_land_in_day_folder_with_keep_note(stack: Stack) -> None: + root = Path(tempfile.mkdtemp(prefix="beaver-att-")) + stack.tg.attachments = Attachments(dir=root, keep_days=3, tz="Asia/Bangkok") + stack.bot.media(_photo("смотри")) + await stack.until(lambda: stack.sent_with("смотри"), what="reply") + prompt = next( + p for c in ScriptedClient.instances for p in c.prompts if "смотри" in p + ) + (day,) = list(root.iterdir()) + assert day.name == stack.tg.attachments.day() + (saved,) = list(day.iterdir()) + assert saved.read_bytes() == b"data" and saved.name.endswith("-u1.jpg") + assert prompt.endswith( + f"смотри\n\n[вложение: фото {saved}, 2 КБ; хранится 3 дн., " + "перенеси в vault, если нужно]" + ) + stack.tg.attachments = Attachments(dir=root, keep_days=None) + stack.bot.media(_photo("ещё")) + await stack.until(lambda: stack.sent_with("ещё"), what="second reply") + prompt = next(p for c in ScriptedClient.instances for p in c.prompts if "ещё" in p) + assert prompt.endswith(" КБ]") and "хранится" not in prompt + + +async def test_sticker_alone_becomes_an_unsupported_note(stack: Stack) -> None: + stack.bot.media( + { + "sticker": { + "file_id": "s1", + "file_unique_id": "su1", + "type": "regular", + "width": 1, + "height": 1, + "is_animated": False, + "is_video": False, + } + } + ) + await stack.until(lambda: stack.sent_with("стикер"), what="reply") + prompt = next( + p for c in ScriptedClient.instances for p in c.prompts if "стикер" in p + ) + assert prompt.endswith("[вложение: стикер - не поддерживается]") + + +def test_sweep_drops_old_files_and_empty_day_folders() -> None: + root = Path(tempfile.mkdtemp(prefix="beaver-sweep-")) + old = root / "2020-01-01" / "1-old.jpg" + old.parent.mkdir() + old.write_bytes(b"x") + import os + + os.utime(old, (1_577_836_800, 1_577_836_800)) + fresh = root / "2099-01-01" / "2-new.jpg" + fresh.parent.mkdir() + fresh.write_bytes(b"y") + tg = TelegramFrontend( + token="t", user_id=USER, attachments=Attachments(dir=root, keep_days=None) + ) + tg._sweep_attachments() # noqa: SLF001 + assert old.exists() + tg.attachments = Attachments(dir=root, keep_days=7) + tg._sweep_attachments() # noqa: SLF001 + assert not old.parent.exists() and fresh.exists() + + +async def test_topic_rename_updates_the_title(stack: Stack) -> None: + stack.bot.topic_created("план", 7) + stack.bot.message("hello", thread=7) + await stack.until(lambda: stack.sent_with("hello"), what="reply") + stack.bot.topic_edited("новое имя", 7) + + async def titled() -> Any: + conv = await stack.world.conversations.find_bound( + frontend="telegram", external_id=f"{USER}/7" + ) + return conv if conv is not None and conv.title == "новое имя" else None + + deadline = asyncio.get_running_loop().time() + 5 + branch = None + while branch is None and asyncio.get_running_loop().time() < deadline: + branch = await titled() + await asyncio.sleep(0.02) + assert branch is not None + stack.tg._topic_names.clear() # noqa: SLF001 + assert await stack.tg.mark_topic(branch) + assert stack.bot.topics[-1] == "edit:7:✅ новое имя" + + +async def test_unknown_callback_strips_the_keyboard(stack: Stack) -> None: + stack.bot.callback("q:nope:0:1", 555) + await stack.until( + lambda: {"message_id": 555, "markup": None} in stack.bot.edits, + what="keyboard removed", + ) + + +async def test_failed_background_turn_is_reported_once(stack: Stack) -> None: + stack.bot.message("hi") + await stack.until(lambda: stack.sent_with("hi"), what="reply") + master = await stack.world.conversations.find_bound( + frontend="telegram", external_id=GENERAL + ) + event = { + "type": "turn.end", + "conversation_id": master.external_id, + "turn_id": "t-err", + "origin": "крон", + "stop": "error", + } + await stack.tg._on_event(event) # noqa: SLF001 + await stack.tg._on_event(event) # noqa: SLF001 + await stack.until( + lambda: stack.sent_with("фоновый тёрн (крон) упал"), what="notice" + ) + await asyncio.sleep(0.2) + assert len([m for m in stack.bot.sent if "упал" in m["text"]]) == 1 + await stack.tg._on_event({**event, "turn_id": "t-user", "origin": "user"}) # noqa: SLF001 + notice = await stack.until(lambda: stack.sent_with("⚠️ тёрн упал"), what="user") + assert "фоновый" not in notice["text"] + + +async def test_gone_topic_unbinds_and_the_next_message_starts_afresh( + stack: Stack, +) -> None: + stack.bot.message("hello", thread=7) + await stack.until(lambda: stack.sent_with("hello"), what="reply") + branch = await stack.world.conversations.find_bound( + frontend="telegram", external_id=f"{USER}/7" + ) + stack.bot.gone_threads.add(7) + await stack.world.conversations.say(branch, "psst") + + async def unbound() -> bool: + rows = await stack.world.conversations.bindings(branch) + return all(not b.visible for b in rows) + + deadline = asyncio.get_running_loop().time() + 5 + while not await unbound() and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.02) + assert await unbound() + assert branch.external_id not in stack.tg._targets # noqa: SLF001 + async with stack.world.db.session() as session: + rows = list((await session.exec(select(Delivery))).all()) + assert [r.status for r in rows if r.text == "psst"] == ["failed"] + stack.bot.gone_threads.clear() + stack.bot.message("again", thread=7) + await stack.until(lambda: stack.sent_with("again"), what="fresh reply") + fresh = await stack.world.conversations.find_bound( + frontend="telegram", external_id=f"{USER}/7" + ) + assert fresh is not None and fresh.id != branch.id and fresh.status == "open"