From 3915ab48f938ec787d41466c24a9de3c133b6f81 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 2 Sep 2026 00:16:38 +0200 Subject: [PATCH] refactor(telegram): every user-facing string lives in TelegramTexts with English defaults --- .../frontends/telegram/__init__.py | 5 +- .../frontends/telegram/drafts.py | 2 +- .../frontends/telegram/frontend.py | 166 ++++++++++-------- .../frontends/telegram/render.py | 33 +--- .../frontends/telegram/texts.py | 105 +++++++++++ tests/test_telegram.py | 36 ++-- 6 files changed, 227 insertions(+), 120 deletions(-) create mode 100644 src/beaver_gateway/frontends/telegram/texts.py diff --git a/src/beaver_gateway/frontends/telegram/__init__.py b/src/beaver_gateway/frontends/telegram/__init__.py index 687778a..2a25c3b 100644 --- a/src/beaver_gateway/frontends/telegram/__init__.py +++ b/src/beaver_gateway/frontends/telegram/__init__.py @@ -1,5 +1,6 @@ -"""Telegram frontend (§3.8): General = master, topic = branch, drafts, inbox/outbox.""" +"""Telegram frontend: General = master, topic = branch, drafts, inbox/outbox.""" from beaver_gateway.frontends.telegram.frontend import Attachments, TelegramFrontend +from beaver_gateway.frontends.telegram.texts import TelegramTexts -__all__ = ["Attachments", "TelegramFrontend"] +__all__ = ["Attachments", "TelegramFrontend", "TelegramTexts"] diff --git a/src/beaver_gateway/frontends/telegram/drafts.py b/src/beaver_gateway/frontends/telegram/drafts.py index 15a4912..439fd2f 100644 --- a/src/beaver_gateway/frontends/telegram/drafts.py +++ b/src/beaver_gateway/frontends/telegram/drafts.py @@ -43,7 +43,7 @@ class Draft: thread_id: int | None, turn_id: str, interval: float = 0.7, - status: str = "⏳ думаю", + status: str = "⏳ thinking", ) -> None: self._bot = bot self._chat_id = chat_id diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index 47d569b..c18cc9f 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -42,6 +42,7 @@ from beaver_gateway.frontends.telegram.drafts import Draft from beaver_gateway.frontends.telegram.inbox import Inbox from beaver_gateway.frontends.telegram.outbox import Outbox from beaver_gateway.frontends.telegram.render import chunks, status_label +from beaver_gateway.frontends.telegram.texts import TelegramTexts if TYPE_CHECKING: from beaver_gateway.conversations.kinds import Kind @@ -56,17 +57,7 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram") FRONTEND = "telegram" _DONE = "done" -_COMMAND_HELP = ( - ("merge", "слить ветку в мастер"), - ("new", "новая ветка: в General - новый топик, в топике - заново на нём"), - ("chat", "открыть глубокий чат в vault: /chat тема"), - ("status", "что с этим разговором"), # noqa: RUF001 - ("help", "команды"), -) -_COMMANDS = (*(c for c, _ in _COMMAND_HELP), "start") -_HELP = "General - мастер, любой другой топик - ветка.\n" + "\n".join( - f"/{c} - {d}" for c, d in _COMMAND_HELP -) +_COMMANDS = ("merge", "new", "chat", "status", "help", "start") @dataclass(frozen=True, slots=True) @@ -131,8 +122,10 @@ class TelegramFrontend(Frontend): queued_reaction: str = "👀", poll_timeout: int = 30, outbox_backoff: float = 2.0, + texts: TelegramTexts | None = None, ) -> None: self._token = token + self.texts = texts or TelegramTexts() self.user_id = user_id self.chat_id = chat_id if chat_id is not None else user_id self.master_agent = master_agent @@ -184,7 +177,7 @@ class TelegramFrontend(Frontend): getattr(me, "has_topics_enabled", None), ) await self.bot.set_my_commands( - [BotCommand(command=c, description=d) for c, d in _COMMAND_HELP] + [BotCommand(command=c, description=d) for c, d in self.texts.commands] ) try: async with asyncio.TaskGroup() as tg: @@ -205,7 +198,7 @@ class TelegramFrontend(Frontend): if conv.kind != "branch": return None topic = await self.bot.create_forum_topic( - self.chat_id, name=(conv.title or "ветка")[:128] + self.chat_id, name=(conv.title or self.texts.branch)[:128] ) self._topic_names[topic.message_thread_id] = topic.name return await self.conversations.bind( @@ -215,12 +208,13 @@ class TelegramFrontend(Frontend): async def mark_closed(self, conv: Conversation) -> bool: return await self.mark_topic(conv) - async def mark_topic(self, conv: Conversation, prefix: str = "✅ ") -> bool: + async def mark_topic(self, conv: Conversation, prefix: str | None = None) -> bool: """Rename the topic; closeForumTopic does not exist in private chats.""" + prefix = self.texts.closed_prefix if prefix is None else prefix target = await self._target_of(conv) if target is None or target[1] is None: return False - name = self._topic_names.get(target[1]) or conv.title or "ветка" + name = self._topic_names.get(target[1]) or conv.title or self.texts.branch if name.startswith(prefix): return True await self.bot.edit_forum_topic( @@ -353,7 +347,9 @@ class TelegramFrontend(Frontend): kind="branch", seed="morning", parent=master, - title=(title or self._topic_names.get(thread_id) or "ветка")[:128], + title=(title or self._topic_names.get(thread_id) or self.texts.branch)[ + :128 + ], text=text, origin=FRONTEND, binding=(FRONTEND, self._ext(thread_id)), @@ -423,7 +419,9 @@ class TelegramFrontend(Frontend): return pending = self.conversations.pending_question(conv.external_id) if pending is not None and self.conversations.answer(pending[0], text): - await self._close_ask(pending[0], f"✍️ {text}") + await self._close_ask( + pending[0], self.texts.question_typed.format(text=text) + ) return item = await self.conversations.post(conv, text, origin=FRONTEND) if conv.running_turn or conv.pending_question: @@ -474,52 +472,50 @@ class TelegramFrontend(Frontend): name: str | None = None kind = "" size: int | None = None + texts = self.texts if message.sticker: - return "[вложение: стикер - не поддерживается]" + return texts.attachment_sticker if message.animation: - return "[вложение: анимация - не поддерживается]" + return texts.attachment_animation if message.photo: photo = message.photo[-1] - file_id, kind, size = photo.file_id, "фото", photo.file_size + file_id, kind, size = photo.file_id, "photo", photo.file_size name = f"{photo.file_unique_id}.jpg" elif message.document: doc = message.document - file_id, kind, size = doc.file_id, "файл", doc.file_size + file_id, kind, size = doc.file_id, "document", doc.file_size name = doc.file_name or f"{doc.file_unique_id}.bin" elif message.voice: file_id, kind, size = ( message.voice.file_id, - "голосовое", + "voice", message.voice.file_size, ) name = f"{message.voice.file_unique_id}.ogg" elif message.audio: file_id, kind, size = ( message.audio.file_id, - "аудио", + "audio", message.audio.file_size, ) name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3" elif message.video: file_id, kind, size = ( message.video.file_id, - "видео", + "video", message.video.file_size, ) name = message.video.file_name or f"{message.video.file_unique_id}.mp4" elif message.video_note: - file_id, kind, size = ( - message.video_note.file_id, - "кружок", - message.video_note.file_size, - ) - name = f"{message.video_note.file_unique_id}.mp4" + note = message.video_note + file_id, kind, size = note.file_id, "video_note", note.file_size + name = f"{note.file_unique_id}.mp4" if file_id is None or name is None: return None + kind = texts.attachment_kinds.get(kind, kind) if size and size > 20 * 1024 * 1024: - return ( - f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - " - "больше 20 МБ, Telegram не отдаёт ботам]" + return texts.attachment_too_big.format( + kind=kind, name=name, mb=size // 1024 // 1024 ) now = time.time() folder = self.attachments.root / self.attachments.day(now) @@ -533,15 +529,12 @@ class TelegramFrontend(Frontend): path.chmod(0o644) except (TelegramAPIError, OSError) as exc: _log.warning("attachment download failed: %s", exc) - return f"[вложение: {kind} {name} - не скачалось: {exc}]" - shown = f", {size // 1024} КБ" if size else "" + return texts.attachment_failed.format(kind=kind, name=name, error=exc) + shown = texts.attachment_size.format(kb=size // 1024) if size else "" keep = self.attachments.keep_days if keep is None: - return f"[вложение: {kind} {path}{shown}]" - return ( - f"[вложение: {kind} {path}{shown}; хранится {keep} дн., " - "перенеси в vault, если нужно]" - ) + return texts.attachment.format(kind=kind, path=path, size=shown) + return texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep) async def _sweep_loop(self, interval: float = 6 * 3600) -> None: while True: @@ -575,8 +568,9 @@ class TelegramFrontend(Frontend): ) async def _run_command(self, command: str, args: str, thread_id: int | None) -> str: + texts = self.texts if command in ("start", "help"): - return _HELP + return texts.help is_master = ( thread_id is None or self._ext(thread_id) == await self._master_ext() ) @@ -585,9 +579,9 @@ class TelegramFrontend(Frontend): return await self._status(conv) if command == "merge": if conv is None or conv.kind != "branch": - return "сливать нечего: это не открытая ветка" + return texts.merge_nothing self._spawn_task(self._merge(conv)) - return "🔀 сливаю в мастер" + return texts.merging if command == "new": if is_master or thread_id is None: child = await self.conversations.spawn( @@ -597,20 +591,22 @@ class TelegramFrontend(Frontend): title=args or None, origin=FRONTEND, ) - return f"🌿 ветка «{child.title or child.external_id}» - в новом топике" + return texts.new_branch_topic.format( + title=child.title or child.external_id + ) if conv is not None: await self.conversations.set_status(conv, "closed") await self._branch(thread_id, title=args or None, text=None) - return "🌿 новая ветка на этом топике" + return texts.new_branch_here if command == "chat": if not args: - return "/chat <тема>" + return texts.chat_usage try: deep = await self.conversations.spawn( kind="deep", seed="clean", title=args, origin=FRONTEND ) except (ValueError, LookupError) as exc: - return f"не вышло: {exc}" + return texts.chat_failed.format(error=exc) where = next( ( b.external_id @@ -619,12 +615,13 @@ class TelegramFrontend(Frontend): ), deep.external_id, ) - return f"💬 глубокий чат: {where}" - return _HELP + return texts.chat_opened.format(where=where) + return texts.help async def _status(self, conv: Conversation | None) -> str: + texts = self.texts if conv is None: - return "этот топик ни к чему не привязан - напиши, и откроется ветка" + return texts.status_unbound info = await self.conversations.describe(conv) queued = sum( 1 @@ -635,16 +632,22 @@ class TelegramFrontend(Frontend): ) lines = [ f"{conv.kind} · {conv.status} · {conv.agent_name}", - f"сессия: {'живая' if info['live'] else 'нет'}" - f" · тёрн: {'идёт' if conv.running_turn else 'нет'}" - f" · в очереди: {queued}", + texts.status_line.format( + live=texts.status_live if info["live"] else texts.status_dead, + turn=texts.status_running if conv.running_turn else texts.status_idle, + queued=queued, + ), ] if conv.pending_question: - lines.append("❓ ждёт ответа на вопрос") + lines.append(texts.status_question) if conv.kind == "master": pool = self.conversations.pool - lines.append(f"пул: {len(pool)} сессий, rss {pool.rss() // (1 << 20)} МБ") - lines.append(f"outbox: {await self.outbox.pending()} в очереди") + lines.append( + texts.status_pool.format(sessions=len(pool), mb=pool.rss() // (1 << 20)) + ) + lines.append( + texts.status_outbox.format(pending=await self.outbox.pending()) + ) lines.append(f"id: {conv.external_id}") return "\n".join(lines) @@ -653,7 +656,7 @@ class TelegramFrontend(Frontend): await self.conversations.merge(conv) except Exception as exc: # noqa: BLE001 _log.exception("merge of %s failed", conv.external_id) - await self._deliver(conv, f"⚠️ слив не удался: {exc}") + await self._deliver(conv, self.texts.merge_failed.format(error=exc)) def _spawn_task(self, coro: Any) -> None: task = asyncio.create_task(coro) @@ -698,7 +701,7 @@ class TelegramFrontend(Frontend): draft = self._drafts.get(key) if draft is not None and event.get("parent_tool_use_id") is None: draft.set_status( - f"⏳ {status_label(str(event['name']), event.get('input'))}" + self._tool_status(event["name"], event.get("input")) ) case "turn.end": draft = self._drafts.get(key) @@ -708,9 +711,9 @@ class TelegramFrontend(Frontend): origin = event.get("origin") await self._deliver( conv, - "⚠️ тёрн упал, смотри логи gateway" + self.texts.turn_failed if origin == "user" - else f"⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway", + else self.texts.background_failed.format(origin=origin), turn_id=event.get("turn_id"), key=f"{event.get('turn_id')}:error", ) @@ -724,14 +727,17 @@ class TelegramFrontend(Frontend): await self._ask(conv, event, target) case "question.answered": await self._close_ask( - str(event["question_id"]), f"✅ {event.get('answer') or ''}" + str(event["question_id"]), + self.texts.question_answered.format( + answer=event.get("answer") or "" + ), ) case "question.timeout": await self._close_ask( - str(event["question_id"]), "⌛ время вышло - ответь текстом" + str(event["question_id"]), self.texts.question_timeout ) case "conversation.merged": - await self._deliver(conv, "✅ слито в мастер", key=f"{key}:merged") + await self._deliver(conv, self.texts.merged, key=f"{key}:merged") async def _on_delivery_failed(self, event: Event) -> None: row = event.get("conversation_row") @@ -778,7 +784,7 @@ class TelegramFrontend(Frontend): if user_text: await self._deliver( conv, - f"📝 из панели:\n{user_text}", + self.texts.mirrored.format(text=user_text), turn_id=turn_id, key=f"{turn_id}:mirror", ) @@ -798,6 +804,7 @@ class TelegramFrontend(Frontend): thread_id=target[1], turn_id=str(event.get("turn_id") or key), interval=self.draft_interval, + status=self.texts.waiting, ) self._drafts[key] = draft draft.start() @@ -811,16 +818,23 @@ class TelegramFrontend(Frontend): if kind == "content_block_delta": delta = raw.get("delta") or {} if delta.get("type") == "text_delta": - draft.set_status("✍️ пишу") + draft.set_status(self.texts.writing) draft.append(str(delta.get("text") or "")) elif delta.get("type") == "thinking_delta": - draft.set_status("🤔 думаю") + draft.set_status(self.texts.thinking) elif kind == "content_block_start": block = raw.get("content_block") or {} if block.get("type") == "tool_use": - draft.set_status( - f"⏳ {status_label(str(block.get('name') or ''), None)}" - ) + draft.set_status(self._tool_status(block.get("name") or "", None)) + + def _tool_status(self, name: Any, tool_input: Any) -> str: + label = status_label(str(name), tool_input, self.texts.tool_labels) + return self.texts.tool.format(label=label) + + def _keyboard( + self, question_id: str, qi: int, question: dict[str, Any], picked: list[str] + ) -> InlineKeyboardMarkup: + return _keyboard(question_id, qi, question, picked, done=self.texts.done_button) async def _close_draft(self, key: str) -> None: draft = self._drafts.pop(key, None) @@ -850,7 +864,7 @@ class TelegramFrontend(Frontend): _question_html(question), message_thread_id=target[1], parse_mode="HTML", - reply_markup=_keyboard(question_id, qi, question, []), + reply_markup=self._keyboard(question_id, qi, question, []), ) except TelegramAPIError: _log.exception("question %s could not be sent", question_id) @@ -866,7 +880,7 @@ class TelegramFrontend(Frontend): _, question_id, qi_raw, choice = parts ask = self._asks.get(question_id) if ask is None or not qi_raw.isdigit(): - await self._callback_reply(query, "вопрос уже закрыт") + await self._callback_reply(query, self.texts.question_closed) await self._strip_keyboard(query) return qi = int(qi_raw) @@ -901,14 +915,14 @@ class TelegramFrontend(Frontend): await self.bot.edit_message_reply_markup( chat_id=message.chat.id, message_id=message.message_id, - reply_markup=_keyboard(question_id, qi, question, picked), + reply_markup=self._keyboard(question_id, qi, question, picked), ) await self._callback_reply(query, None) if len(ask.done) == len(ask.questions): answer = _answer_text(ask) self._asks.pop(question_id, None) if not self.conversations.answer(question_id, answer): - await self._edit_asks(ask, "⌛ время вышло - ответь текстом") + await self._edit_asks(ask, self.texts.question_timeout) async def _strip_keyboard(self, query: CallbackQuery) -> None: message = query.message if isinstance(query.message, Message) else None @@ -932,7 +946,7 @@ class TelegramFrontend(Frontend): async def _edit_asks(self, ask: _Ask, note: str) -> None: for qi, message_id in enumerate(ask.messages): - if qi in ask.done and not note.startswith("⌛"): + if qi in ask.done and note != self.texts.question_timeout: continue with contextlib.suppress(TelegramAPIError): await self.bot.edit_message_text( @@ -955,7 +969,7 @@ def _question_html(question: dict[str, Any]) -> str: def _keyboard( - question_id: str, qi: int, question: dict[str, Any], picked: list[str] + question_id: str, qi: int, question: dict[str, Any], picked: list[str], *, done: str ) -> InlineKeyboardMarkup: rows = [ [ @@ -971,7 +985,7 @@ def _keyboard( rows.append( [ InlineKeyboardButton( - text="✅ готово", callback_data=f"q:{question_id}:{qi}:{_DONE}" + text=done, callback_data=f"q:{question_id}:{qi}:{_DONE}" ) ] ) diff --git a/src/beaver_gateway/frontends/telegram/render.py b/src/beaver_gateway/frontends/telegram/render.py index 247db74..5e82869 100644 --- a/src/beaver_gateway/frontends/telegram/render.py +++ b/src/beaver_gateway/frontends/telegram/render.py @@ -7,8 +7,10 @@ import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal +from beaver_gateway.frontends.telegram.texts import TOOL_LABELS + if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Mapping __all__ = ["LIMIT", "chunks", "status_label", "to_html", "to_html_tail"] @@ -37,27 +39,6 @@ _ITALIC_U = re.compile(r"(? list[str]: return out -def status_label(name: str, tool_input: dict[str, Any] | None = None) -> str: - label = _LABELS.get(name) +def status_label( + name: str, + tool_input: dict[str, Any] | None = None, + labels: Mapping[str, str] | None = None, +) -> str: + label = (labels if labels is not None else TOOL_LABELS).get(name) if label is not None: return label if name.startswith("mcp__"): diff --git a/src/beaver_gateway/frontends/telegram/texts.py b/src/beaver_gateway/frontends/telegram/texts.py new file mode 100644 index 0000000..4619108 --- /dev/null +++ b/src/beaver_gateway/frontends/telegram/texts.py @@ -0,0 +1,105 @@ +"""Every string the Telegram frontend shows the user; English defaults, overridable.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +__all__ = ["TOOL_LABELS", "TelegramTexts"] + +TOOL_LABELS: dict[str, str] = { + "Read": "reading", + "Glob": "looking for files", + "Grep": "searching", + "Edit": "editing a file", + "Write": "writing a file", + "MultiEdit": "editing files", + "Bash": "running a command", + "WebSearch": "searching the web", + "WebFetch": "reading a page", + "Task": "started a subagent", + "Agent": "started a subagent", + "AskUserQuestion": "asking", + "TodoWrite": "planning", + "Skill": "opening a skill", + "mcp__gateway__spawn": "opening a conversation", + "mcp__gateway__read_conversation": "reading a conversation", + "mcp__gateway__say": "speaking", + "mcp__gateway__schedule": "setting a reminder", + "mcp__gateway__inject": "passing to another conversation", +} + + +@dataclass(frozen=True, slots=True) +class TelegramTexts: + commands: tuple[tuple[str, str], ...] = ( + ("merge", "merge this branch into the master"), + ("new", "new branch: in General a new topic, in a topic a fresh one here"), + ("chat", "open a deep chat in the vault: /chat topic"), + ("status", "this conversation"), + ("help", "commands"), + ) + help_head: str = "General is the master, any other topic is a branch." + branch: str = "branch" + closed_prefix: str = "✅ " + waiting: str = "⏳ thinking" + thinking: str = "🤔 thinking" + writing: str = "✍️ writing" + tool: str = "⏳ {label}" + tool_labels: dict[str, str] = field(default_factory=lambda: dict(TOOL_LABELS)) + attachment_sticker: str = "[attachment: sticker - not supported]" + attachment_animation: str = "[attachment: animation - not supported]" + attachment_kinds: dict[str, str] = field( + default_factory=lambda: { + "photo": "photo", + "document": "file", + "voice": "voice message", + "audio": "audio", + "video": "video", + "video_note": "video note", + } + ) + attachment_too_big: str = ( + "[attachment: {kind} {name}, {mb} MB - over 20 MB, Telegram does not " + "serve it to bots]" + ) + attachment_failed: str = "[attachment: {kind} {name} - download failed: {error}]" + attachment_size: str = ", {kb} KB" + attachment: str = "[attachment: {kind} {path}{size}]" + attachment_kept: str = ( + "[attachment: {kind} {path}{size}; kept {days} days, move it into the " + "vault if it matters]" + ) + merge_nothing: str = "nothing to merge: this is not an open branch" + merging: str = "🔀 merging into the master" + merged: str = "✅ merged into the master" + merge_failed: str = "⚠️ merge failed: {error}" + new_branch_topic: str = "🌿 branch «{title}» - in a new topic" + new_branch_here: str = "🌿 new branch on this topic" + chat_usage: str = "/chat " + chat_failed: str = "could not open: {error}" + chat_opened: str = "💬 deep chat: {where}" + status_unbound: str = "this topic is bound to nothing - write, and a branch opens" + status_line: str = "session: {live} · turn: {turn} · queued: {queued}" + status_live: str = "live" + status_dead: str = "none" + status_running: str = "running" + status_idle: str = "idle" + status_question: str = "❓ waiting for an answer" + status_pool: str = "pool: {sessions} sessions, rss {mb} MB" + status_outbox: str = "outbox: {pending} queued" + turn_failed: str = "⚠️ the turn failed, see the gateway logs" + background_failed: str = ( + "⚠️ a background turn ({origin}) failed, see the gateway logs" + ) + question_closed: str = "the question is already closed" + question_timeout: str = "⌛ time is up - answer as text" + question_answered: str = "✅ {answer}" + question_typed: str = "✍️ {text}" + done_button: str = "✅ done" + mirrored: str = "📝 from the panel:\n{text}" + + @property + def help(self) -> str: + return ( + self.help_head + "\n" + "\n".join(f"/{c} - {d}" for c, d in self.commands) + ) diff --git a/tests/test_telegram.py b/tests/test_telegram.py index e56b6af..e06e0e0 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -293,7 +293,7 @@ async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None: assert all(o[0] != "draft" for o in stack.bot.order[final:]) last = stack.bot.drafts[-1] assert last["text"] == reply["text"] and last["parse_mode"] == "HTML" - assert stack.bot.drafts[0]["text"] == "⏳ думаю" + assert stack.bot.drafts[0]["text"] == "⏳ thinking" rows = await stack.world.conversations.queue.recent(master.id) assert [r.origin for r in rows] == ["telegram"] @@ -377,7 +377,7 @@ async def test_reply_from_another_window_is_mirrored_with_marker(stack: Stack) - await stack.world.conversations.post(master, "from panel", origin="user") await stack.until(lambda: stack.sent_with("ok:from panel"), what="mirrored reply") texts = [m["text"] for m in stack.bot.sent] - marker = next(i for i, t in enumerate(texts) if t.startswith("📝 из панели:")) + marker = next(i for i, t in enumerate(texts) if t.startswith("📝 from the panel:")) assert "from panel" in texts[marker] assert texts[marker + 1] == "ok:from panel" @@ -456,7 +456,7 @@ async def test_question_timeout_renders_text_and_free_text_answers( assert result is None assert "did not answer" in stack.world.conversations.answer_text(None) await stack.until( - lambda: any("время вышло" in e.get("text", "") for e in stack.bot.edits), + lambda: any("time is up" in e.get("text", "") for e in stack.bot.edits), what="timeout edit", ) stack.world.conversations._question_timeout = 5.0 # noqa: SLF001 @@ -474,7 +474,7 @@ async def test_question_timeout_renders_text_and_free_text_answers( async def test_commands_status_merge_and_new(stack: Stack) -> None: stack.bot.message("/status") status = await stack.until(lambda: stack.sent_with("master · open"), what="status") - assert "пул:" in status["text"] + assert "pool:" in status["text"] stack.bot.message("work", thread=11) await stack.until(lambda: stack.sent_with("work"), what="branch reply") branch = await stack.world.conversations.find_bound( @@ -490,14 +490,14 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None: ), ) stack.bot.message("/merge", thread=11) - await stack.until(lambda: stack.sent_with("слито в мастер"), what="merged") + await stack.until(lambda: stack.sent_with("merged into the master"), what="merged") branch = await stack.world.conversations.find_bound( 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") + await stack.until(lambda: stack.sent_with("in a new topic"), what="new topic") assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"] child = await stack.world.conversations.find_bound( frontend="telegram", external_id=f"{USER}/902" @@ -646,7 +646,7 @@ def test_render_helpers() -> None: 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("Read", {"file_path": "/x"}) == "reading" assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts" assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la" @@ -779,14 +779,14 @@ async def test_attachments_land_in_day_folder_with_keep_note(stack: Stack) -> No (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, если нужно]" + f"смотри\n\n[attachment: photo {saved}, 2 KB; kept 3 days, " + "move it into the vault if it matters]" ) 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 + assert prompt.endswith(" KB]") and "kept" not in prompt async def test_sticker_alone_becomes_an_unsupported_note(stack: Stack) -> None: @@ -803,11 +803,11 @@ async def test_sticker_alone_becomes_an_unsupported_note(stack: Stack) -> None: } } ) - await stack.until(lambda: stack.sent_with("стикер"), what="reply") + await stack.until(lambda: stack.sent_with("sticker"), what="reply") prompt = next( - p for c in ScriptedClient.instances for p in c.prompts if "стикер" in p + p for c in ScriptedClient.instances for p in c.prompts if "sticker" in p ) - assert prompt.endswith("[вложение: стикер - не поддерживается]") + assert prompt.endswith("[attachment: sticker - not supported]") def test_sweep_drops_old_files_and_empty_day_folders() -> None: @@ -878,13 +878,15 @@ async def test_failed_background_turn_is_reported_once(stack: Stack) -> None: 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" + lambda: stack.sent_with("a background turn (крон) failed"), what="notice" ) await asyncio.sleep(0.2) - assert len([m for m in stack.bot.sent if "упал" in m["text"]]) == 1 + assert len([m for m in stack.bot.sent if "failed" 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"] + notice = await stack.until( + lambda: stack.sent_with("⚠️ the turn failed"), what="user" + ) + assert "background" not in notice["text"] async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(