Files
beaver-gateway/tests/test_telegram.py
T

920 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
from aiogram.methods import SendMessage
from aiogram.types import Update
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.render import (
LIMIT,
chunks,
status_label,
to_html,
to_html_tail,
)
from beaver_gateway.storage.models import ConversationBinding, Delivery, TelegramUpdate
from sqlmodel import select
from test_conversations import ScriptedClient, StubFrontend, World
USER = 42
GENERAL = f"{USER}/901"
class FakeBot:
def __init__(self) -> None:
self.updates: list[dict[str, Any]] = []
self.sent: list[dict[str, Any]] = []
self.drafts: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = []
self.topics: list[str] = []
self.reactions: list[tuple[int, list[Any]]] = []
self.fail_sends = 0
self.reject_html = False
self.gone_threads: set[int] = set()
self.order: list[tuple[str, int | None]] = []
self._message_id = 100
self.session = SimpleNamespace(close=self._close)
async def _close(self) -> None:
pass
async def get_me(self) -> Any:
return SimpleNamespace(username="bot", has_topics_enabled=True)
async def get_updates(self, offset=None, **_: Any) -> list[Update]:
await asyncio.sleep(0.01)
return [
Update.model_validate(u)
for u in self.updates
if offset is None or u["update_id"] >= offset
]
async def send_message(
self, chat_id, text, message_thread_id=None, parse_mode=None, **kwargs: Any
) -> Any:
if self.fail_sends:
self.fail_sends -= 1
raise TelegramNetworkError(
method=SendMessage(chat_id=0, text="x"), message="boom"
)
if self.reject_html and parse_mode == "HTML":
raise TelegramBadRequest(
method=SendMessage(chat_id=0, text="x"),
message="Bad Request: can't parse entities",
)
if message_thread_id in self.gone_threads:
raise TelegramBadRequest(
method=SendMessage(chat_id=0, text="x"),
message="Bad Request: message thread not found",
)
self._message_id += 1
self.order.append(("message", message_thread_id))
self.sent.append(
{
"chat_id": chat_id,
"thread": message_thread_id,
"text": text,
"parse_mode": parse_mode,
"markup": kwargs.get("reply_markup"),
"message_id": self._message_id,
}
)
return SimpleNamespace(message_id=self._message_id)
async def send_message_draft(self, **kwargs: Any) -> bool:
await asyncio.sleep(0.08)
self.drafts.append(kwargs)
self.order.append(("draft", kwargs.get("message_thread_id")))
return True
async def create_forum_topic(self, chat_id, name, **_: Any) -> Any:
self.topics.append(name)
created = sum(1 for t in self.topics if not t.startswith("edit:"))
return SimpleNamespace(message_thread_id=900 + created, name=name)
async def edit_forum_topic(
self, chat_id, message_thread_id, name=None, **_: Any
) -> bool:
self.topics.append(f"edit:{message_thread_id}:{name}")
return True
async def set_message_reaction(
self, chat_id, message_id, reaction=None, **_: Any
) -> bool:
self.reactions.append((message_id, reaction or []))
return True
async def answer_callback_query(self, *_: Any, **__: Any) -> bool:
return True
async def edit_message_text(self, text, chat_id, message_id, **_: Any) -> Any:
self.edits.append({"message_id": message_id, "text": text})
return True
async def edit_message_reply_markup(
self, chat_id, message_id, reply_markup=None
) -> Any:
self.edits.append({"message_id": message_id, "markup": reply_markup})
return True
async def download(self, file_id, destination=None) -> None:
Path(destination).write_bytes(b"data")
# helpers for tests
def push(self, payload: dict[str, Any]) -> None:
payload["update_id"] = 1000 + len(self.updates)
self.updates.append(payload)
def message(self, text: str, *, thread: int | None = None, uid: int = USER) -> None:
body: dict[str, Any] = {
"message_id": 10 + len(self.updates),
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": uid, "is_bot": False, "first_name": "h"},
"text": text,
}
if thread is not None:
body |= {"message_thread_id": thread, "is_topic_message": True}
self.push({"message": body})
def topic_created(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_created": {"name": name, "icon_color": 1},
}
}
)
def topic_edited(self, name: str, thread: int) -> None:
self.push(
{
"message": {
"message_id": 10 + len(self.updates),
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"message_thread_id": thread,
"is_topic_message": True,
"forum_topic_edited": {"name": name},
}
}
)
def media(self, body: dict[str, Any], *, thread: int | None = None) -> None:
message: dict[str, Any] = {
"message_id": 10 + len(self.updates),
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
**body,
}
if thread is not None:
message |= {"message_thread_id": thread, "is_topic_message": True}
self.push({"message": message})
def callback(self, data: str, message_id: int) -> None:
self.push(
{
"callback_query": {
"id": f"cb{len(self.updates)}",
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"chat_instance": "ci",
"data": data,
"message": {
"message_id": message_id,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"text": "?",
},
}
}
)
class Stack:
def __init__(self, world: World, bot: FakeBot) -> None:
self.world = world
self.bot = bot
self.tg = TelegramFrontend(
token="t",
user_id=USER,
master_agent="a",
branch_agent="a",
draft_interval=0.05,
outbox_backoff=0.01,
)
self.tg._bot = bot # noqa: SLF001
markdown = StubFrontend("markdown", ("deep",), {"deep": "d"}, home=True)
markdown.conversations = world.conversations
frontends = [self.tg, markdown]
world.conversations._frontends = frontends # noqa: SLF001
runtime = GatewayRuntime(
agents=world.conversations._agents, # noqa: SLF001
mcps=McpRegistry([]),
backends=world.conversations._backends, # noqa: SLF001
token_store=None,
db=world.db,
conversations=world.conversations,
bus=world.bus,
pool=world.pool,
frontends=tuple(frontends),
)
self.tg.configure(runtime)
self.tasks = [
asyncio.create_task(self.tg.inbox.run()),
asyncio.create_task(self.tg.outbox.run()),
asyncio.create_task(self.tg._events()), # noqa: SLF001
]
async def close(self) -> None:
for t in self.tasks:
t.cancel()
for t in self.tasks:
with pytest.raises(BaseException):
await t
async def until(self, pred, timeout: float = 5.0, what: str = "condition") -> Any:
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
value = pred()
if value:
return value
await asyncio.sleep(0.02)
msg = f"{what} never happened; sent={self.bot.sent}"
raise AssertionError(msg)
def sent_with(self, needle: str) -> dict[str, Any] | None:
return next((m for m in self.bot.sent if needle in m["text"]), None)
@pytest.fixture
async def stack() -> Stack:
root = Path(tempfile.mkdtemp(prefix="beaver-tg-"))
world = await World(root).setup()
await world.conversations.start()
s = Stack(world, FakeBot())
yield s
await s.close()
await world.conversations.stop()
await world.pool.close_all()
await world.db.dispose()
async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None:
stack.bot.message("hi")
reply = await stack.until(lambda: stack.sent_with("hi"), what="reply")
assert reply["text"].startswith("ok:[seed: clean] master")
assert reply["thread"] == 901
assert reply["parse_mode"] == "HTML"
assert stack.bot.topics == ["🦫 General"]
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert master is not None and master.kind == "master"
assert stack.bot.drafts and stack.bot.drafts[0]["chat_id"] == USER
await asyncio.sleep(0.2)
final = max(i for i, o in enumerate(stack.bot.order) if o[0] == "message")
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"] == "⏳ думаю"
rows = await stack.world.conversations.queue.recent(master.id)
assert [r.origin for r in rows] == ["telegram"]
async def test_new_topic_becomes_morning_branch_and_replies_in_thread(
stack: Stack,
) -> None:
stack.bot.topic_created("план", 7)
stack.bot.message("hello topic", thread=7)
reply = await stack.until(lambda: stack.sent_with("hello topic"), what="reply")
assert reply["thread"] == 7
assert len([m for m in stack.bot.sent if m["thread"] == 7]) == 1
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/7"
)
assert branch is not None
assert branch.kind == "branch" and branch.title == "план"
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert branch.parent_id == master.id
prompts = [p for c in ScriptedClient.instances for p in c.prompts]
seed = next(p for p in prompts if p.startswith("[seed: morning] branch «план»"))
assert "No handout arrived." in seed
assert seed.endswith("hello topic")
assert stack.bot.drafts[-1]["message_thread_id"] == 7
async def test_first_message_without_service_message_seeds_with_text(
stack: Stack,
) -> None:
stack.bot.message("сразу текстом", thread=8)
await stack.until(lambda: stack.sent_with("сразу текстом"), what="reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/8"
)
assert branch.title == "сразу текстом"
client = next(
c for c in ScriptedClient.instances if c.prompts and "сразу" in c.prompts[0]
)
assert client.prompts[0].startswith("[seed: morning]")
assert client.prompts[0].endswith("сразу текстом")
assert len(client.prompts) == 1
async def test_message_into_merged_branch_rebinds_the_topic(stack: Stack) -> None:
stack.bot.message("one", thread=9)
await stack.until(lambda: stack.sent_with("ok:"), what="first reply")
old = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/9"
)
await stack.world.conversations.set_status(old, "merged")
stack.bot.message("two", thread=9)
await stack.until(lambda: stack.sent_with("two"), what="second reply")
new = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/9"
)
assert new.id != old.id and new.status == "open"
async with stack.world.db.session() as session:
rows = list(
(
await session.exec(
select(ConversationBinding).where(
ConversationBinding.external_id == f"{USER}/9"
)
)
).all()
)
assert {(r.conversation_id, r.visible) for r in rows} == {
(old.id, False),
(new.id, True),
}
async def test_reply_from_another_window_is_mirrored_with_marker(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
)
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("📝 из панели:"))
assert "from panel" in texts[marker]
assert texts[marker + 1] == "ok:from panel"
async def test_say_is_delivered_and_inject_turns_are_silent(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
)
before = len(stack.bot.sent)
await stack.world.conversations.inject(
master, "cron tick", urgency="urgent", origin="крон"
)
await stack.world.settle(master, 2)
await asyncio.sleep(0.2)
assert len(stack.bot.sent) == before
await stack.world.conversations.say(master, "psst")
await stack.until(lambda: stack.sent_with("psst"), what="say")
async def test_question_becomes_buttons_and_callback_answers(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
)
payload = {
"questions": [
{
"header": "Цвет",
"question": "Какой цвет?",
"options": [
{"label": "Синий", "description": "как небо"},
{"label": "Красный", "description": ""},
],
"multiSelect": False,
}
]
}
asking = asyncio.create_task(
stack.world.conversations.ask(master.external_id, payload)
)
question = await stack.until(
lambda: stack.sent_with("Какой цвет?"), what="question"
)
assert question["markup"] is not None
labels = [b.text for row in question["markup"].inline_keyboard for b in row]
assert labels == ["Синий", "Красный"]
pending = stack.world.conversations.pending_question(master.external_id)
assert pending is not None
stack.bot.callback(f"q:{pending[0]}:0:1", question["message_id"])
assert await asyncio.wait_for(asking, 5) == "Красный"
assert stack.world.conversations.answer_text("Красный") == (
"The user answered: Красный"
)
await stack.until(
lambda: any("✅ Красный" in e.get("text", "") for e in stack.bot.edits),
what="edit",
)
row = await stack.world.conversations.get(master.external_id)
assert row.pending_question is False
async def test_question_timeout_renders_text_and_free_text_answers(
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
)
stack.world.conversations._question_timeout = 0.3 # noqa: SLF001
payload = {"questions": [{"header": "Q", "question": "Сколько?", "options": []}]}
result = await stack.world.conversations.ask(master.external_id, payload)
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),
what="timeout edit",
)
stack.world.conversations._question_timeout = 5.0 # noqa: SLF001
asking = asyncio.create_task(
stack.world.conversations.ask(master.external_id, payload)
)
await stack.until(
lambda: stack.world.conversations.pending_question(master.external_id),
what="pending",
)
stack.bot.message("семь")
assert await asyncio.wait_for(asking, 5) == "семь"
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"]
stack.bot.message("work", thread=11)
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/11"
)
await stack.world.store.append(
stack.world.key(branch.session_id),
build_entries(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
session_id=branch.session_id,
cwd=str(stack.world.root),
model="m",
),
)
stack.bot.message("/merge", thread=11)
await stack.until(lambda: stack.sent_with("слито в мастер"), 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")
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
child = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/902"
)
assert child is not None and child.title == "отчёт"
await asyncio.sleep(0.2)
assert await stack.world.conversations.queue.recent(child.id) == []
assert all(m["thread"] != 902 for m in stack.bot.sent)
assert all(d["message_thread_id"] != 902 for d in stack.bot.drafts)
stack.bot.message("первое в новый топик", thread=902)
reply = await stack.until(lambda: stack.sent_with("первое в новый топик"), what="r")
assert reply["thread"] == 902
assert reply["text"].startswith("ok:[seed: morning] branch «отчёт»")
assert await stack.tg.mark_topic(child)
assert stack.bot.topics[-1] == "edit:902:✅ отчёт"
async def test_brief_branch_from_the_tool_replies_in_its_topic(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
)
child = await stack.world.conversations.spawn(
kind="branch",
seed="brief",
text="найди X",
title="X",
parent=master,
origin="mcp",
)
reply = await stack.until(lambda: stack.sent_with("найди X"), what="brief reply")
assert reply["thread"] == 902
assert not any(m["text"].startswith("📝") for m in stack.bot.sent)
assert any(d["message_thread_id"] == 902 for d in stack.bot.drafts)
assert (await stack.world.conversations.get(child.external_id)).flags == {}
async def test_master_topic_survives_rotation(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
)
stack.bot.message("into general", thread=901)
await stack.until(lambda: stack.sent_with("into general"), what="reply")
assert (await stack.world.conversations.find(kind="branch")) == []
await stack.world.conversations.set_status(master, "closed")
stack.bot.message("after rotation", thread=901)
await stack.until(lambda: stack.sent_with("after rotation"), what="reply")
fresh = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
assert fresh.kind == "master" and fresh.id != master.id
assert stack.bot.topics == ["🦫 General"]
assert (await stack.world.conversations.find(kind="branch")) == []
async def test_inbox_stores_first_and_replays_after_restart(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
async with stack.world.db.session() as session:
rows = list((await session.exec(select(TelegramUpdate))).all())
assert [r.update_id for r in rows] == [1000]
assert rows[0].processed_at is not None and rows[0].error is None
async with stack.world.db.session() as session:
session.add(
TelegramUpdate(
update_id=999,
payload={
"update_id": 999,
"message": {
"message_id": 1,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"text": "replayed",
},
},
)
)
await session.commit()
await stack.until(
lambda: stack.sent_with("replayed"), timeout=8, what="replayed reply"
)
stack.bot.message("stranger", uid=1)
await asyncio.sleep(0.3)
assert stack.sent_with("stranger") is None
async def test_outbox_retries_and_falls_back_to_plain(stack: Stack) -> None:
stack.bot.fail_sends = 2
stack.bot.reject_html = True
stack.bot.message("**bold**")
reply = await stack.until(lambda: stack.sent_with("**bold**"), what="reply")
assert reply["parse_mode"] is None
async with stack.world.db.session() as session:
rows = list((await session.exec(select(Delivery))).all())
sent = [r for r in rows if r.status == "sent"]
assert sent and sent[0].plain and sent[0].attempts >= 3
async def test_can_use_tool_answers_through_deny(stack: Stack) -> None:
seen: list[tuple[str, dict[str, Any]]] = []
async def asker(key: str, payload: dict[str, Any]) -> str:
seen.append((key, payload))
return "Пользователь ответил: да"
backend = stack.world.backend
backend._asker = asker # noqa: SLF001
can_use_tool = backend._can_use_tool("conv-1") # noqa: SLF001
allow = await can_use_tool("Bash", {"command": "ls"}, None)
assert isinstance(allow, PermissionResultAllow)
deny = await can_use_tool("AskUserQuestion", {"questions": []}, None)
assert isinstance(deny, PermissionResultDeny)
assert deny.message == "Пользователь ответил: да"
assert seen == [("conv-1", {"questions": []})]
def test_render_helpers() -> None:
assert (
to_html("**жирно** и `code` <b>")
== "<b>жирно</b> и <code>code</code> &lt;b&gt;"
)
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
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 (
to_html("[док](https://a.b/c?x=1&y=2)")
== '<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)
assert parts == ["абв\n\n" + "г" * 30, "д" * 30]
assert chunks(" \n ") == []
assert status_label("Read", {"file_path": "/x"}) == "читаю vault"
assert status_label("mcp__firefly__list_accounts", None) == "firefly: list_accounts"
assert status_label("Foo", {"command": "ls -la"}) == "Foo ls -la"
def test_render_wikilinks() -> None:
assert to_html("[[Note]]") == "<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:
stack.bot.push(
{
"message": {
"message_id": 77,
"date": 1700000000,
"chat": {"id": USER, "type": "private"},
"from": {"id": USER, "is_bot": False, "first_name": "h"},
"text": "see https://x.y",
"link_preview_options": {"url": "https://x.y"},
}
}
)
await stack.until(lambda: stack.sent_with("see https://x.y"), what="reply")
async with stack.world.db.session() as session:
rows = list((await session.exec(select(TelegramUpdate))).all())
preview = rows[0].payload["message"]["link_preview_options"]
assert preview["url"] == "https://x.y"
assert preview.get("is_disabled") is None
def _photo(caption: str) -> dict[str, Any]:
return {
"caption": caption,
"photo": [
{
"file_id": "f1",
"file_unique_id": "u1",
"width": 1,
"height": 1,
"file_size": 2048,
}
],
}
async def test_attachments_land_in_day_folder_with_keep_note(stack: Stack) -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-att-"))
stack.tg.attachments = Attachments(dir=root, keep_days=3, tz="Asia/Bangkok")
stack.bot.media(_photo("смотри"))
await stack.until(lambda: stack.sent_with("смотри"), what="reply")
prompt = next(
p for c in ScriptedClient.instances for p in c.prompts if "смотри" in p
)
(day,) = list(root.iterdir())
assert day.name == stack.tg.attachments.day()
(saved,) = list(day.iterdir())
assert saved.read_bytes() == b"data" and saved.name.endswith("-u1.jpg")
assert prompt.endswith(
f"смотри\n\n[вложение: фото {saved}, 2 КБ; хранится 3 дн., "
"перенеси в vault, если нужно]"
)
stack.tg.attachments = Attachments(dir=root, keep_days=None)
stack.bot.media(_photo("ещё"))
await stack.until(lambda: stack.sent_with("ещё"), what="second reply")
prompt = next(p for c in ScriptedClient.instances for p in c.prompts if "ещё" in p)
assert prompt.endswith(" КБ]") and "хранится" not in prompt
async def test_sticker_alone_becomes_an_unsupported_note(stack: Stack) -> None:
stack.bot.media(
{
"sticker": {
"file_id": "s1",
"file_unique_id": "su1",
"type": "regular",
"width": 1,
"height": 1,
"is_animated": False,
"is_video": False,
}
}
)
await stack.until(lambda: stack.sent_with("стикер"), what="reply")
prompt = next(
p for c in ScriptedClient.instances for p in c.prompts if "стикер" in p
)
assert prompt.endswith("[вложение: стикер - не поддерживается]")
def test_sweep_drops_old_files_and_empty_day_folders() -> None:
root = Path(tempfile.mkdtemp(prefix="beaver-sweep-"))
old = root / "2020-01-01" / "1-old.jpg"
old.parent.mkdir()
old.write_bytes(b"x")
import os
os.utime(old, (1_577_836_800, 1_577_836_800))
fresh = root / "2099-01-01" / "2-new.jpg"
fresh.parent.mkdir()
fresh.write_bytes(b"y")
tg = TelegramFrontend(
token="t", user_id=USER, attachments=Attachments(dir=root, keep_days=None)
)
tg._sweep_attachments() # noqa: SLF001
assert old.exists()
tg.attachments = Attachments(dir=root, keep_days=7)
tg._sweep_attachments() # noqa: SLF001
assert not old.parent.exists() and fresh.exists()
async def test_topic_rename_updates_the_title(stack: Stack) -> None:
stack.bot.topic_created("план", 7)
stack.bot.message("hello", thread=7)
await stack.until(lambda: stack.sent_with("hello"), what="reply")
stack.bot.topic_edited("новое имя", 7)
async def titled() -> Any:
conv = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/7"
)
return conv if conv is not None and conv.title == "новое имя" else None
deadline = asyncio.get_running_loop().time() + 5
branch = None
while branch is None and asyncio.get_running_loop().time() < deadline:
branch = await titled()
await asyncio.sleep(0.02)
assert branch is not None
stack.tg._topic_names.clear() # noqa: SLF001
assert await stack.tg.mark_topic(branch)
assert stack.bot.topics[-1] == "edit:7:✅ новое имя"
async def test_unknown_callback_strips_the_keyboard(stack: Stack) -> None:
stack.bot.callback("q:nope:0:1", 555)
await stack.until(
lambda: {"message_id": 555, "markup": None} in stack.bot.edits,
what="keyboard removed",
)
async def test_failed_background_turn_is_reported_once(stack: Stack) -> None:
stack.bot.message("hi")
await stack.until(lambda: stack.sent_with("hi"), what="reply")
master = await stack.world.conversations.find_bound(
frontend="telegram", external_id=GENERAL
)
event = {
"type": "turn.end",
"conversation_id": master.external_id,
"turn_id": "t-err",
"origin": "крон",
"stop": "error",
}
await stack.tg._on_event(event) # noqa: SLF001
await stack.tg._on_event(event) # noqa: SLF001
await stack.until(
lambda: stack.sent_with("фоновый тёрн (крон) упал"), what="notice"
)
await asyncio.sleep(0.2)
assert len([m for m in stack.bot.sent if "упал" in m["text"]]) == 1
await stack.tg._on_event({**event, "turn_id": "t-user", "origin": "user"}) # noqa: SLF001
notice = await stack.until(lambda: stack.sent_with("⚠️ тёрн упал"), what="user")
assert "фоновый" not in notice["text"]
async def test_gone_topic_unbinds_and_the_next_message_starts_afresh(
stack: Stack,
) -> None:
stack.bot.message("hello", thread=7)
await stack.until(lambda: stack.sent_with("hello"), what="reply")
branch = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/7"
)
stack.bot.gone_threads.add(7)
await stack.world.conversations.say(branch, "psst")
async def unbound() -> bool:
rows = await stack.world.conversations.bindings(branch)
return all(not b.visible for b in rows)
deadline = asyncio.get_running_loop().time() + 5
while not await unbound() and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.02)
assert await unbound()
assert branch.external_id not in stack.tg._targets # noqa: SLF001
async with stack.world.db.session() as session:
rows = list((await session.exec(select(Delivery))).all())
assert [r.status for r in rows if r.text == "psst"] == ["failed"]
stack.bot.gone_threads.clear()
stack.bot.message("again", thread=7)
await stack.until(lambda: stack.sent_with("again"), what="fresh reply")
fresh = await stack.world.conversations.find_bound(
frontend="telegram", external_id=f"{USER}/7"
)
assert fresh is not None and fresh.id != branch.id and fresh.status == "open"