fix(telegram,conversations): attachments by day with sweep in both modes, merge renames the topic, gone topics unbind, failures visible

This commit is contained in:
hh
2026-09-01 23:13:22 +02:00
parent 7853d61b94
commit b96714338f
3 changed files with 422 additions and 28 deletions
+111 -21
View File
@@ -19,8 +19,10 @@ import logging
import tempfile import tempfile
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast from typing import TYPE_CHECKING, Any, Literal, cast
from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
@@ -71,14 +73,21 @@ _HELP = "General - мастер, любой другой топик - ветка
class Attachments: class Attachments:
"""Where files from Telegram go. """Where files from Telegram go.
``ephemeral`` - under the gateway's data dir, swept after ``keep_days``; Files land in ``<root>/YYYY-MM-DD/<unixts>-<name>`` (the day in ``tz``)
``vault`` - into a directory the agent owns. Pulling what is worth and whatever is older than ``keep_days`` is swept; ``None`` never
keeping into the vault is the agent's job either way. 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" mode: Literal["ephemeral", "vault"] = "ephemeral"
dir: Path | None = None 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 @property
def root(self) -> Path: def root(self) -> Path:
@@ -174,7 +183,6 @@ class TelegramFrontend(Frontend):
self.chat_id, self.chat_id,
getattr(me, "has_topics_enabled", None), getattr(me, "has_topics_enabled", None),
) )
self._sweep_attachments()
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 _COMMAND_HELP]
) )
@@ -183,6 +191,7 @@ class TelegramFrontend(Frontend):
tg.create_task(self.inbox.run()) tg.create_task(self.inbox.run())
tg.create_task(self.outbox.run()) tg.create_task(self.outbox.run())
tg.create_task(self._events()) tg.create_task(self._events())
tg.create_task(self._sweep_loop())
finally: finally:
for draft in list(self._drafts.values()): for draft in list(self._drafts.values()):
await draft.stop() await draft.stop()
@@ -373,7 +382,11 @@ class TelegramFrontend(Frontend):
return return
if message.chat.id != self.chat_id: if message.chat.id != self.chat_id:
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
or message.forum_topic_edited 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 = ( 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()
@@ -382,10 +395,12 @@ class TelegramFrontend(Frontend):
return return
if message.forum_topic_edited is not None and thread_id is not None: if message.forum_topic_edited is not None and thread_id is not None:
if message.forum_topic_edited.name: 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 return
text = (message.text or message.caption or "").strip() text = (message.text or message.caption or "").strip()
attachment = await self._save_attachment(message, thread_id) attachment = await self._save_attachment(message)
if attachment: if attachment:
text = f"{text}\n\n{attachment}".strip() text = f"{text}\n\n{attachment}".strip()
if not text: if not text:
@@ -414,6 +429,14 @@ class TelegramFrontend(Frontend):
if conv.running_turn or conv.pending_question: if conv.running_turn or conv.pending_question:
await self._react(message, item.id) 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: async def _live(self, thread_id: int) -> Conversation | None:
conv = await self.conversations.find_bound( conv = await self.conversations.find_bound(
frontend=FRONTEND, external_id=self._ext(thread_id) frontend=FRONTEND, external_id=self._ext(thread_id)
@@ -446,13 +469,15 @@ class TelegramFrontend(Frontend):
with contextlib.suppress(TelegramAPIError): with contextlib.suppress(TelegramAPIError):
await self.bot.set_message_reaction(target[0], target[1], reaction=[]) await self.bot.set_message_reaction(target[0], target[1], reaction=[])
async def _save_attachment( async def _save_attachment(self, message: Message) -> str | None:
self, message: Message, thread_id: int | None
) -> str | None:
file_id: str | None = None file_id: str | None = None
name: str | None = None name: str | None = None
kind = "" kind = ""
size: int | None = None size: int | None = None
if message.sticker:
return "[вложение: стикер - не поддерживается]"
if message.animation:
return "[вложение: анимация - не поддерживается]"
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.file_size
@@ -496,12 +521,13 @@ class TelegramFrontend(Frontend):
f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - " f"[вложение: {kind} {name}, {size // 1024 // 1024} МБ - "
"больше 20 МБ, Telegram не отдаёт ботам]" "больше 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) folder.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
self.attachments.root.chmod(0o755) self.attachments.root.chmod(0o755)
folder.chmod(0o755) folder.chmod(0o755)
path = folder / f"{int(time.time())}-{Path(name).name}" path = folder / f"{int(now)}-{Path(name).name}"
try: try:
await self.bot.download(file_id, destination=path) await self.bot.download(file_id, destination=path)
path.chmod(0o644) path.chmod(0o644)
@@ -509,19 +535,33 @@ class TelegramFrontend(Frontend):
_log.warning("attachment download failed: %s", exc) _log.warning("attachment download failed: %s", exc)
return f"[вложение: {kind} {name} - не скачалось: {exc}]" return f"[вложение: {kind} {name} - не скачалось: {exc}]"
shown = f", {size // 1024} КБ" if size else "" 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: def _sweep_attachments(self) -> None:
if self.attachments.mode != "ephemeral": keep = self.attachments.keep_days
return
root = self.attachments.root root = self.attachments.root
if not root.exists(): if keep is None or not root.exists():
return return
cutoff = time.time() - self.attachments.keep_days * 86400 cutoff = time.time() - keep * 86400
for path in root.rglob("*"): for path in root.rglob("*"):
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
if path.is_file() and path.stat().st_mtime < cutoff: if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink() path.unlink()
for folder in root.iterdir():
with contextlib.suppress(OSError):
if folder.is_dir() and not any(folder.iterdir()):
folder.rmdir()
# ---- commands -------------------------------------------------------- # ---- commands --------------------------------------------------------
@@ -634,7 +674,10 @@ class TelegramFrontend(Frontend):
if kind == "conversation.bound": if kind == "conversation.bound":
self._targets.pop(str(event.get("conversation_id")), None) self._targets.pop(str(event.get("conversation_id")), None)
return 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 return
key = event.get("conversation_id") key = event.get("conversation_id")
if not isinstance(key, str): if not isinstance(key, str):
@@ -661,10 +704,13 @@ class TelegramFrontend(Frontend):
draft = self._drafts.get(key) draft = self._drafts.get(key)
if draft is not None: if draft is not None:
await draft.stop() 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( await self._deliver(
conv, conv,
"⚠️ тёрн упал, смотри логи gateway", "⚠️ тёрн упал, смотри логи gateway"
if origin == "user"
else f"⚠️ фоновый тёрн ({origin}) упал, смотри логи gateway",
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",
) )
@@ -687,6 +733,38 @@ class TelegramFrontend(Frontend):
case "conversation.merged": case "conversation.merged":
await self._deliver(conv, "✅ слито в мастер", key=f"{key}: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: async def _on_reply(self, conv: Conversation, event: Event) -> None:
turn_id = str(event.get("turn_id") or "") turn_id = str(event.get("turn_id") or "")
origin = str(event.get("item_origin") or "") origin = str(event.get("item_origin") or "")
@@ -789,6 +867,7 @@ class TelegramFrontend(Frontend):
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, "вопрос уже закрыт")
await self._strip_keyboard(query)
return return
qi = int(qi_raw) qi = int(qi_raw)
question = ask.questions[qi] question = ask.questions[qi]
@@ -831,6 +910,17 @@ class TelegramFrontend(Frontend):
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, "⌛ время вышло - ответь текстом")
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: async def _callback_reply(self, query: CallbackQuery, text: str | None) -> None:
with contextlib.suppress(TelegramAPIError): with contextlib.suppress(TelegramAPIError):
await self.bot.answer_callback_query(query.id, text=text) await self.bot.answer_callback_query(query.id, text=text)
@@ -143,7 +143,7 @@ class Outbox:
if "parse" in text and not row.plain: if "parse" in text and not row.plain:
await self._retry(row, str(exc), delay=0.0, plain=True) await self._retry(row, str(exc), delay=0.0, plain=True)
elif any(marker in text for marker in _GONE): elif any(marker in text for marker in _GONE):
await self._fail(row, str(exc)) await self._fail(row, str(exc), gone=True)
else: else:
await self._fail(row, str(exc)) await self._fail(row, str(exc))
except (TelegramNotFound, TelegramForbiddenError) as exc: except (TelegramNotFound, TelegramForbiddenError) as exc:
@@ -180,7 +180,7 @@ class Outbox:
if delay < 5.0: if delay < 5.0:
self._wake.set() 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( _log.error(
"delivery #%s to %s/%s given up: %s", "delivery #%s to %s/%s given up: %s",
row.id, row.id,
@@ -196,6 +196,7 @@ class Outbox:
chat_id=row.chat_id, chat_id=row.chat_id,
thread_id=row.thread_id, thread_id=row.thread_id,
error=error, error=error,
gone=gone,
) )
async def _mark( async def _mark(
+308 -5
View File
@@ -13,8 +13,14 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.core.registry import McpRegistry from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.transcript import build_entries from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import TelegramFrontend from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html 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 beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
from sqlmodel import select from sqlmodel import select
from test_conversations import ScriptedClient, StubFrontend, World from test_conversations import ScriptedClient, StubFrontend, World
@@ -33,6 +39,7 @@ class FakeBot:
self.reactions: list[tuple[int, list[Any]]] = [] self.reactions: list[tuple[int, list[Any]]] = []
self.fail_sends = 0 self.fail_sends = 0
self.reject_html = False self.reject_html = False
self.gone_threads: set[int] = set()
self.order: list[tuple[str, int | None]] = [] self.order: list[tuple[str, int | None]] = []
self._message_id = 100 self._message_id = 100
self.session = SimpleNamespace(close=self._close) self.session = SimpleNamespace(close=self._close)
@@ -64,6 +71,11 @@ class FakeBot:
method=SendMessage(chat_id=0, text="x"), method=SendMessage(chat_id=0, text="x"),
message="Bad Request: can't parse entities", 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._message_id += 1
self.order.append(("message", message_thread_id)) self.order.append(("message", message_thread_id))
self.sent.append( self.sent.append(
@@ -86,7 +98,8 @@ class FakeBot:
async def create_forum_topic(self, chat_id, name, **_: Any) -> Any: async def create_forum_topic(self, chat_id, name, **_: Any) -> Any:
self.topics.append(name) 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( async def edit_forum_topic(
self, chat_id, message_thread_id, name=None, **_: Any 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: def callback(self, data: str, message_id: int) -> None:
self.push( self.push(
{ {
@@ -455,9 +495,10 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None:
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"]
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 == ["🦫 General", "отчёт"] 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"
) )
@@ -583,18 +624,112 @@ def test_render_helpers() -> None:
== "<b>жирно</b> и <code>code</code> &lt;b&gt;" == "<b>жирно</b> и <code>code</code> &lt;b&gt;"
) )
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт" assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
assert to_html("```py\nx = 1\n```") == "<pre>x = 1</pre>" assert to_html("```\nx < 1 & y\n```") == "<pre>x &lt; 1 &amp; y</pre>"
assert (
to_html("```py\nunterminated\n")
== '<pre><code class="language-py">unterminated</code></pre>'
)
assert ( assert (
to_html("[док](https://a.b/c?x=1&y=2)") to_html("[док](https://a.b/c?x=1&y=2)")
== '<a href="https://a.b/c?x=1&amp;y=2">док</a>' == '<a href="https://a.b/c?x=1&amp;y=2">док</a>'
) )
assert (
to_html("[tg](tg://user?id=1) https://x.y/a_b_c")
== '<a href="tg://user?id=1">tg</a> https://x.y/a_b_c'
)
assert to_html("![alt](https://x.y/i.png) ![](https://x.y/j.png)") == (
'<a href="https://x.y/i.png">alt</a> <a href="https://x.y/j.png">https://x.y/j.png</a>'
)
assert to_html("~~нет~~ _к_ *к* __ж__") == "<s>нет</s> <i>к</i> <i>к</i> <b>ж</b>"
assert to_html("---\n***\n___") == "———\n———\n———"
assert to_html("`**raw** [[x]]`") == "<code>**raw** [[x]]</code>"
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 status_label("Read", {"file_path": "/x"}) == "читаю vault" assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
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"
def test_render_wikilinks() -> None:
assert to_html("[[Note]]") == "<u>Note</u>"
assert to_html("[[Note|alias]]") == "<u>alias</u>"
assert to_html("[[Note#heading]]") == "<u>Note heading</u>"
assert to_html("[[A & B|**x**]]") == "<u>**x**</u>"
def test_render_blocks() -> None:
assert (
to_html("> цитата **жирно**\n> вторая\n\nтекст")
== "<blockquote>цитата <b>жирно</b>\nвторая</blockquote>\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 |")
== "<pre>a | bb\nccc | d\ne</pre>"
)
assert (
to_html("```python\nprint(1)\n```")
== '<pre><code class="language-python">print(1)</code></pre>'
)
assert (
to_html("||секрет|| и `||нет||`")
== "<tg-spoiler>секрет</tg-spoiler> и <code>||нет||</code>"
)
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('<pre><code class="language-python">')
assert to_html(p).endswith("</code></pre>")
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("<blockquote>") and rendered.endswith(
"</blockquote>"
)
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('<pre><code class="language-py">code ')
assert rendered.endswith("</code></pre>\nafter")
assert "```" not in rendered
assert to_html_tail("a\n> b\n> c", 3) == "<blockquote>c</blockquote>"
assert to_html_tail("**x** y", 100) == "<b>x</b> 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: async def test_message_with_a_link_preview_is_stored_and_answered(stack: Stack) -> None:
stack.bot.push( 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"] preview = rows[0].payload["message"]["link_preview_options"]
assert preview["url"] == "https://x.y" assert preview["url"] == "https://x.y"
assert preview.get("is_disabled") is None 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"