refactor(telegram): every user-facing string lives in TelegramTexts with English defaults

This commit is contained in:
hh
2026-09-02 00:16:38 +02:00
parent cae2ed4161
commit 90d0fb0f00
6 changed files with 227 additions and 120 deletions
@@ -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.frontend import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.texts import TelegramTexts
__all__ = ["Attachments", "TelegramFrontend"] __all__ = ["Attachments", "TelegramFrontend", "TelegramTexts"]
@@ -43,7 +43,7 @@ class Draft:
thread_id: int | None, thread_id: int | None,
turn_id: str, turn_id: str,
interval: float = 0.7, interval: float = 0.7,
status: str = "думаю", status: str = "thinking",
) -> None: ) -> None:
self._bot = bot self._bot = bot
self._chat_id = chat_id self._chat_id = chat_id
@@ -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.inbox import Inbox
from beaver_gateway.frontends.telegram.outbox import Outbox from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import chunks, status_label from beaver_gateway.frontends.telegram.render import chunks, status_label
from beaver_gateway.frontends.telegram.texts import TelegramTexts
if TYPE_CHECKING: if TYPE_CHECKING:
from beaver_gateway.conversations.kinds import Kind from beaver_gateway.conversations.kinds import Kind
@@ -56,17 +57,7 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
FRONTEND = "telegram" FRONTEND = "telegram"
_DONE = "done" _DONE = "done"
_COMMAND_HELP = ( _COMMANDS = ("merge", "new", "chat", "status", "help", "start")
("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
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -131,8 +122,10 @@ class TelegramFrontend(Frontend):
queued_reaction: str = "👀", queued_reaction: str = "👀",
poll_timeout: int = 30, poll_timeout: int = 30,
outbox_backoff: float = 2.0, outbox_backoff: float = 2.0,
texts: TelegramTexts | None = None,
) -> None: ) -> None:
self._token = token self._token = token
self.texts = texts or TelegramTexts()
self.user_id = user_id self.user_id = user_id
self.chat_id = chat_id if chat_id is not None else user_id self.chat_id = chat_id if chat_id is not None else user_id
self.master_agent = master_agent self.master_agent = master_agent
@@ -184,7 +177,7 @@ class TelegramFrontend(Frontend):
getattr(me, "has_topics_enabled", None), getattr(me, "has_topics_enabled", None),
) )
await self.bot.set_my_commands( 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: try:
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
@@ -205,7 +198,7 @@ class TelegramFrontend(Frontend):
if conv.kind != "branch": if conv.kind != "branch":
return None return None
topic = await self.bot.create_forum_topic( 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 self._topic_names[topic.message_thread_id] = topic.name
return await self.conversations.bind( return await self.conversations.bind(
@@ -215,12 +208,13 @@ class TelegramFrontend(Frontend):
async def mark_closed(self, conv: Conversation) -> bool: async def mark_closed(self, conv: Conversation) -> bool:
return await self.mark_topic(conv) 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.""" """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) target = await self._target_of(conv)
if target is None or target[1] is None: if target is None or target[1] is None:
return False 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): if name.startswith(prefix):
return True return True
await self.bot.edit_forum_topic( await self.bot.edit_forum_topic(
@@ -353,7 +347,9 @@ class TelegramFrontend(Frontend):
kind="branch", kind="branch",
seed="morning", seed="morning",
parent=master, 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, text=text,
origin=FRONTEND, origin=FRONTEND,
binding=(FRONTEND, self._ext(thread_id)), binding=(FRONTEND, self._ext(thread_id)),
@@ -423,7 +419,9 @@ class TelegramFrontend(Frontend):
return return
pending = self.conversations.pending_question(conv.external_id) pending = self.conversations.pending_question(conv.external_id)
if pending is not None and self.conversations.answer(pending[0], text): 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 return
item = await self.conversations.post(conv, text, origin=FRONTEND) item = await self.conversations.post(conv, text, origin=FRONTEND)
if conv.running_turn or conv.pending_question: if conv.running_turn or conv.pending_question:
@@ -474,52 +472,50 @@ class TelegramFrontend(Frontend):
name: str | None = None name: str | None = None
kind = "" kind = ""
size: int | None = None size: int | None = None
texts = self.texts
if message.sticker: if message.sticker:
return "[вложение: стикер - не поддерживается]" return texts.attachment_sticker
if message.animation: if message.animation:
return "[вложение: анимация - не поддерживается]" return texts.attachment_animation
if message.photo: if message.photo:
photo = message.photo[-1] 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" name = f"{photo.file_unique_id}.jpg"
elif message.document: elif message.document:
doc = 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" name = doc.file_name or f"{doc.file_unique_id}.bin"
elif message.voice: elif message.voice:
file_id, kind, size = ( file_id, kind, size = (
message.voice.file_id, message.voice.file_id,
"голосовое", "voice",
message.voice.file_size, message.voice.file_size,
) )
name = f"{message.voice.file_unique_id}.ogg" name = f"{message.voice.file_unique_id}.ogg"
elif message.audio: elif message.audio:
file_id, kind, size = ( file_id, kind, size = (
message.audio.file_id, message.audio.file_id,
"аудио", "audio",
message.audio.file_size, message.audio.file_size,
) )
name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3" name = message.audio.file_name or f"{message.audio.file_unique_id}.mp3"
elif message.video: elif message.video:
file_id, kind, size = ( file_id, kind, size = (
message.video.file_id, message.video.file_id,
"видео", "video",
message.video.file_size, message.video.file_size,
) )
name = message.video.file_name or f"{message.video.file_unique_id}.mp4" name = message.video.file_name or f"{message.video.file_unique_id}.mp4"
elif message.video_note: elif message.video_note:
file_id, kind, size = ( note = message.video_note
message.video_note.file_id, file_id, kind, size = note.file_id, "video_note", note.file_size
"кружок", name = f"{note.file_unique_id}.mp4"
message.video_note.file_size,
)
name = f"{message.video_note.file_unique_id}.mp4"
if file_id is None or name is None: if file_id is None or name is None:
return None return None
kind = texts.attachment_kinds.get(kind, kind)
if size and size > 20 * 1024 * 1024: if size and size > 20 * 1024 * 1024:
return ( return texts.attachment_too_big.format(
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - " kind=kind, name=name, mb=size // 1024 // 1024
"больше 20 МБ, Telegram не отдаёт ботам]"
) )
now = time.time() now = time.time()
folder = self.attachments.root / self.attachments.day(now) folder = self.attachments.root / self.attachments.day(now)
@@ -533,15 +529,12 @@ class TelegramFrontend(Frontend):
path.chmod(0o644) path.chmod(0o644)
except (TelegramAPIError, OSError) as exc: except (TelegramAPIError, OSError) as exc:
_log.warning("attachment download failed: %s", exc) _log.warning("attachment download failed: %s", exc)
return f"[вложение: {kind} {name} - не скачалось: {exc}]" return texts.attachment_failed.format(kind=kind, name=name, error=exc)
shown = f", {size // 1024} КБ" if size else "" shown = texts.attachment_size.format(kb=size // 1024) if size else ""
keep = self.attachments.keep_days keep = self.attachments.keep_days
if keep is None: if keep is None:
return f"[вложение: {kind} {path}{shown}]" return texts.attachment.format(kind=kind, path=path, size=shown)
return ( return texts.attachment_kept.format(kind=kind, path=path, size=shown, days=keep)
f"[вложение: {kind} {path}{shown}; хранится {keep} дн., "
"перенеси в vault, если нужно]"
)
async def _sweep_loop(self, interval: float = 6 * 3600) -> None: async def _sweep_loop(self, interval: float = 6 * 3600) -> None:
while True: while True:
@@ -575,8 +568,9 @@ 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:
texts = self.texts
if command in ("start", "help"): if command in ("start", "help"):
return _HELP return texts.help
is_master = ( is_master = (
thread_id is None or self._ext(thread_id) == await self._master_ext() 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) return await self._status(conv)
if command == "merge": if command == "merge":
if conv is None or conv.kind != "branch": if conv is None or conv.kind != "branch":
return "сливать нечего: это не открытая ветка" return texts.merge_nothing
self._spawn_task(self._merge(conv)) self._spawn_task(self._merge(conv))
return "🔀 сливаю в мастер" return texts.merging
if command == "new": if command == "new":
if is_master or thread_id is None: if is_master or thread_id is None:
child = await self.conversations.spawn( child = await self.conversations.spawn(
@@ -597,20 +591,22 @@ class TelegramFrontend(Frontend):
title=args or None, title=args or None,
origin=FRONTEND, 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: if conv is not None:
await self.conversations.set_status(conv, "closed") await self.conversations.set_status(conv, "closed")
await self._branch(thread_id, title=args or None, text=None) await self._branch(thread_id, title=args or None, text=None)
return "🌿 новая ветка на этом топике" return texts.new_branch_here
if command == "chat": if command == "chat":
if not args: if not args:
return "/chat <тема>" return texts.chat_usage
try: try:
deep = await self.conversations.spawn( deep = await self.conversations.spawn(
kind="deep", seed="clean", title=args, origin=FRONTEND kind="deep", seed="clean", title=args, origin=FRONTEND
) )
except (ValueError, LookupError) as exc: except (ValueError, LookupError) as exc:
return f"не вышло: {exc}" return texts.chat_failed.format(error=exc)
where = next( where = next(
( (
b.external_id b.external_id
@@ -619,12 +615,13 @@ class TelegramFrontend(Frontend):
), ),
deep.external_id, deep.external_id,
) )
return f"💬 глубокий чат: {where}" return texts.chat_opened.format(where=where)
return _HELP return texts.help
async def _status(self, conv: Conversation | None) -> str: async def _status(self, conv: Conversation | None) -> str:
texts = self.texts
if conv is None: if conv is None:
return "этот топик ни к чему не привязан - напиши, и откроется ветка" return texts.status_unbound
info = await self.conversations.describe(conv) info = await self.conversations.describe(conv)
queued = sum( queued = sum(
1 1
@@ -635,16 +632,22 @@ class TelegramFrontend(Frontend):
) )
lines = [ lines = [
f"{conv.kind} · {conv.status} · {conv.agent_name}", f"{conv.kind} · {conv.status} · {conv.agent_name}",
f"сессия: {'живая' if info['live'] else 'нет'}" texts.status_line.format(
f" · тёрн: {'идёт' if conv.running_turn else 'нет'}" live=texts.status_live if info["live"] else texts.status_dead,
f" · в очереди: {queued}", turn=texts.status_running if conv.running_turn else texts.status_idle,
queued=queued,
),
] ]
if conv.pending_question: if conv.pending_question:
lines.append("❓ ждёт ответа на вопрос") lines.append(texts.status_question)
if conv.kind == "master": if conv.kind == "master":
pool = self.conversations.pool pool = self.conversations.pool
lines.append(f"пул: {len(pool)} сессий, rss {pool.rss() // (1 << 20)} МБ") lines.append(
lines.append(f"outbox: {await self.outbox.pending()} в очереди") 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}") lines.append(f"id: {conv.external_id}")
return "\n".join(lines) return "\n".join(lines)
@@ -653,7 +656,7 @@ class TelegramFrontend(Frontend):
await self.conversations.merge(conv) await self.conversations.merge(conv)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_log.exception("merge of %s failed", conv.external_id) _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: def _spawn_task(self, coro: Any) -> None:
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
@@ -698,7 +701,7 @@ class TelegramFrontend(Frontend):
draft = self._drafts.get(key) draft = self._drafts.get(key)
if draft is not None and event.get("parent_tool_use_id") is None: if draft is not None and event.get("parent_tool_use_id") is None:
draft.set_status( draft.set_status(
f"{status_label(str(event['name']), event.get('input'))}" self._tool_status(event["name"], event.get("input"))
) )
case "turn.end": case "turn.end":
draft = self._drafts.get(key) draft = self._drafts.get(key)
@@ -708,9 +711,9 @@ class TelegramFrontend(Frontend):
origin = event.get("origin") origin = event.get("origin")
await self._deliver( await self._deliver(
conv, conv,
"⚠️ тёрн упал, смотри логи gateway" self.texts.turn_failed
if origin == "user" if origin == "user"
else f"⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway", else self.texts.background_failed.format(origin=origin),
turn_id=event.get("turn_id"), turn_id=event.get("turn_id"),
key=f"{event.get('turn_id')}:error", key=f"{event.get('turn_id')}:error",
) )
@@ -724,14 +727,17 @@ class TelegramFrontend(Frontend):
await self._ask(conv, event, target) await self._ask(conv, event, target)
case "question.answered": case "question.answered":
await self._close_ask( 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": case "question.timeout":
await self._close_ask( await self._close_ask(
str(event["question_id"]), "⌛ время вышло - ответь текстом" str(event["question_id"]), self.texts.question_timeout
) )
case "conversation.merged": 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: async def _on_delivery_failed(self, event: Event) -> None:
row = event.get("conversation_row") row = event.get("conversation_row")
@@ -778,7 +784,7 @@ class TelegramFrontend(Frontend):
if user_text: if user_text:
await self._deliver( await self._deliver(
conv, conv,
f"📝 из панели:\n{user_text}", self.texts.mirrored.format(text=user_text),
turn_id=turn_id, turn_id=turn_id,
key=f"{turn_id}:mirror", key=f"{turn_id}:mirror",
) )
@@ -798,6 +804,7 @@ class TelegramFrontend(Frontend):
thread_id=target[1], thread_id=target[1],
turn_id=str(event.get("turn_id") or key), turn_id=str(event.get("turn_id") or key),
interval=self.draft_interval, interval=self.draft_interval,
status=self.texts.waiting,
) )
self._drafts[key] = draft self._drafts[key] = draft
draft.start() draft.start()
@@ -811,16 +818,23 @@ class TelegramFrontend(Frontend):
if kind == "content_block_delta": if kind == "content_block_delta":
delta = raw.get("delta") or {} delta = raw.get("delta") or {}
if delta.get("type") == "text_delta": if delta.get("type") == "text_delta":
draft.set_status("✍️ пишу") draft.set_status(self.texts.writing)
draft.append(str(delta.get("text") or "")) draft.append(str(delta.get("text") or ""))
elif delta.get("type") == "thinking_delta": elif delta.get("type") == "thinking_delta":
draft.set_status("🤔 думаю") draft.set_status(self.texts.thinking)
elif kind == "content_block_start": elif kind == "content_block_start":
block = raw.get("content_block") or {} block = raw.get("content_block") or {}
if block.get("type") == "tool_use": if block.get("type") == "tool_use":
draft.set_status( draft.set_status(self._tool_status(block.get("name") or "", None))
f"{status_label(str(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: async def _close_draft(self, key: str) -> None:
draft = self._drafts.pop(key, None) draft = self._drafts.pop(key, None)
@@ -850,7 +864,7 @@ class TelegramFrontend(Frontend):
_question_html(question), _question_html(question),
message_thread_id=target[1], message_thread_id=target[1],
parse_mode="HTML", parse_mode="HTML",
reply_markup=_keyboard(question_id, qi, question, []), reply_markup=self._keyboard(question_id, qi, question, []),
) )
except TelegramAPIError: except TelegramAPIError:
_log.exception("question %s could not be sent", question_id) _log.exception("question %s could not be sent", question_id)
@@ -866,7 +880,7 @@ class TelegramFrontend(Frontend):
_, question_id, qi_raw, choice = parts _, question_id, qi_raw, choice = parts
ask = self._asks.get(question_id) ask = self._asks.get(question_id)
if ask is None or not qi_raw.isdigit(): 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) await self._strip_keyboard(query)
return return
qi = int(qi_raw) qi = int(qi_raw)
@@ -901,14 +915,14 @@ class TelegramFrontend(Frontend):
await self.bot.edit_message_reply_markup( await self.bot.edit_message_reply_markup(
chat_id=message.chat.id, chat_id=message.chat.id,
message_id=message.message_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) await self._callback_reply(query, None)
if len(ask.done) == len(ask.questions): if len(ask.done) == len(ask.questions):
answer = _answer_text(ask) answer = _answer_text(ask)
self._asks.pop(question_id, None) self._asks.pop(question_id, None)
if not self.conversations.answer(question_id, answer): 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: async def _strip_keyboard(self, query: CallbackQuery) -> None:
message = query.message if isinstance(query.message, Message) else 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: async def _edit_asks(self, ask: _Ask, note: str) -> None:
for qi, message_id in enumerate(ask.messages): 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 continue
with contextlib.suppress(TelegramAPIError): with contextlib.suppress(TelegramAPIError):
await self.bot.edit_message_text( await self.bot.edit_message_text(
@@ -955,7 +969,7 @@ def _question_html(question: dict[str, Any]) -> str:
def _keyboard( 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: ) -> InlineKeyboardMarkup:
rows = [ rows = [
[ [
@@ -971,7 +985,7 @@ def _keyboard(
rows.append( rows.append(
[ [
InlineKeyboardButton( InlineKeyboardButton(
text="✅ готово", callback_data=f"q:{question_id}:{qi}:{_DONE}" text=done, callback_data=f"q:{question_id}:{qi}:{_DONE}"
) )
] ]
) )
@@ -7,8 +7,10 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal
from beaver_gateway.frontends.telegram.texts import TOOL_LABELS
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable, Mapping
__all__ = ["LIMIT", "chunks", "status_label", "to_html", "to_html_tail"] __all__ = ["LIMIT", "chunks", "status_label", "to_html", "to_html_tail"]
@@ -37,27 +39,6 @@ _ITALIC_U = re.compile(r"(?<![\w_])_(?!\s)(.+?)(?<!\s)_(?![\w_])")
_STRIKE = re.compile(r"~~(.+?)~~") _STRIKE = re.compile(r"~~(.+?)~~")
_SPOILER = re.compile(r"\|\|(.+?)\|\|") _SPOILER = re.compile(r"\|\|(.+?)\|\|")
_LABELS: dict[str, str] = {
"Read": "читаю vault",
"Glob": "ищу файлы",
"Grep": "ищу в vault",
"Edit": "правлю файл",
"Write": "пишу файл",
"MultiEdit": "правлю файлы",
"Bash": "выполняю команду",
"WebSearch": "ищу в сети",
"WebFetch": "читаю страницу",
"Task": "запустил сабагента",
"Agent": "запустил сабагента",
"AskUserQuestion": "спрашиваю",
"TodoWrite": "планирую",
"Skill": "открываю скилл",
"mcp__gateway__spawn": "открываю разговор",
"mcp__gateway__read_conversation": "читаю разговор",
"mcp__gateway__say": "говорю",
"mcp__gateway__schedule": "ставлю напоминание",
"mcp__gateway__inject": "передаю в другой разговор",
}
_Kind = Literal["fence", "quote", "table", "text"] _Kind = Literal["fence", "quote", "table", "text"]
@@ -100,8 +81,12 @@ def chunks(text: str, limit: int = LIMIT) -> list[str]:
return out return out
def status_label(name: str, tool_input: dict[str, Any] | None = None) -> str: def status_label(
label = _LABELS.get(name) 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: if label is not None:
return label return label
if name.startswith("mcp__"): if name.startswith("mcp__"):
@@ -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 <topic>"
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)
)
+19 -17
View File
@@ -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:]) assert all(o[0] != "draft" for o in stack.bot.order[final:])
last = stack.bot.drafts[-1] last = stack.bot.drafts[-1]
assert last["text"] == reply["text"] and last["parse_mode"] == "HTML" 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) rows = await stack.world.conversations.queue.recent(master.id)
assert [r.origin for r in rows] == ["telegram"] 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.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")
texts = [m["text"] for m in stack.bot.sent] 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 "from panel" in texts[marker]
assert texts[marker + 1] == "ok:from panel" 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 result is None
assert "did not answer" in stack.world.conversations.answer_text(None) assert "did not answer" in stack.world.conversations.answer_text(None)
await stack.until( 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", what="timeout edit",
) )
stack.world.conversations._question_timeout = 5.0 # noqa: SLF001 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: async def test_commands_status_merge_and_new(stack: Stack) -> None:
stack.bot.message("/status") stack.bot.message("/status")
status = await stack.until(lambda: stack.sent_with("master · open"), what="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) stack.bot.message("work", thread=11)
await stack.until(lambda: stack.sent_with("work"), what="branch reply") await stack.until(lambda: stack.sent_with("work"), what="branch reply")
branch = await stack.world.conversations.find_bound( 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) 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( branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/11" frontend="telegram", external_id=f"{USER}/11"
) )
assert branch.status == "merged" assert branch.status == "merged"
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"] assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"]
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("in a new topic"), what="new topic")
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"] assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
child = await stack.world.conversations.find_bound( child = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/902" 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) parts = chunks("абв\n\n" + "г" * 30 + "\n\n" + "д" * 30, limit=40)
assert parts == ["абв\n\n" + "г" * 30, "д" * 30] assert parts == ["абв\n\n" + "г" * 30, "д" * 30]
assert chunks(" \n ") == [] 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("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la" 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()) (saved,) = list(day.iterdir())
assert saved.read_bytes() == b"data" and saved.name.endswith("-u1.jpg") assert saved.read_bytes() == b"data" and saved.name.endswith("-u1.jpg")
assert prompt.endswith( assert prompt.endswith(
f"смотри\n\n[вложение: фото {saved}, 2 КБ; хранится 3 дн., " f"смотри\n\n[attachment: photo {saved}, 2 KB; kept 3 days, "
"перенеси в vault, если нужно]" "move it into the vault if it matters]"
) )
stack.tg.attachments = Attachments(dir=root, keep_days=None) stack.tg.attachments = Attachments(dir=root, keep_days=None)
stack.bot.media(_photo("ещё")) stack.bot.media(_photo("ещё"))
await stack.until(lambda: stack.sent_with("ещё"), what="second reply") 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) 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: 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( 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: 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.tg._on_event(event) # noqa: SLF001 await stack.tg._on_event(event) # noqa: SLF001
await stack.until( await stack.until(
lambda: stack.sent_with("фоновый тёрн (крон) упал"), what="notice" lambda: stack.sent_with("a background turn (крон) failed"), what="notice"
) )
await asyncio.sleep(0.2) 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 await stack.tg._on_event({**event, "turn_id": "t-user", "origin": "user"}) # noqa: SLF001
notice = await stack.until(lambda: stack.sent_with("⚠️ тёрн упал"), what="user") notice = await stack.until(
assert "фоновый" not in notice["text"] 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( async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(