922 lines
36 KiB
Python
922 lines
36 KiB
Python
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"] == "⏳ thinking"
|
||
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("📝 from the panel:"))
|
||
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("time is up" in e.get("text", "") for e in stack.bot.edits),
|
||
what="timeout edit",
|
||
)
|
||
stack.world.conversations._question_timeout = 5.0 # noqa: SLF001
|
||
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 "pool:" in status["text"]
|
||
stack.bot.message("work", thread=11)
|
||
await stack.until(lambda: stack.sent_with("work"), what="branch reply")
|
||
branch = await stack.world.conversations.find_bound(
|
||
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("merged into the master"), what="merged")
|
||
branch = await stack.world.conversations.find_bound(
|
||
frontend="telegram", external_id=f"{USER}/11"
|
||
)
|
||
assert branch.status == "merged"
|
||
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"]
|
||
stack.bot.message("/new отчёт")
|
||
await stack.until(lambda: stack.sent_with("in a new topic"), what="new topic")
|
||
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
|
||
child = await stack.world.conversations.find_bound(
|
||
frontend="telegram", external_id=f"{USER}/902"
|
||
)
|
||
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> <b>"
|
||
)
|
||
assert to_html("# Заголовок\n- пункт") == "<b>Заголовок</b>\n• пункт"
|
||
assert to_html("```\nx < 1 & y\n```") == "<pre>x < 1 & 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&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(" ") == (
|
||
'<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"}) == "reading"
|
||
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[attachment: photo {saved}, 2 KB; kept 3 days, "
|
||
"move it into the vault if it matters]"
|
||
)
|
||
stack.tg.attachments = Attachments(dir=root, keep_days=None)
|
||
stack.bot.media(_photo("ещё"))
|
||
await stack.until(lambda: stack.sent_with("ещё"), what="second reply")
|
||
prompt = next(p for c in ScriptedClient.instances for p in c.prompts if "ещё" in p)
|
||
assert prompt.endswith(" KB]") and "kept" 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("sticker"), what="reply")
|
||
prompt = next(
|
||
p for c in ScriptedClient.instances for p in c.prompts if "sticker" in p
|
||
)
|
||
assert prompt.endswith("[attachment: sticker - not supported]")
|
||
|
||
|
||
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("a background turn (крон) failed"), what="notice"
|
||
)
|
||
await asyncio.sleep(0.2)
|
||
assert len([m for m in stack.bot.sent if "failed" in m["text"]]) == 1
|
||
await stack.tg._on_event({**event, "turn_id": "t-user", "origin": "user"}) # noqa: SLF001
|
||
notice = await stack.until(
|
||
lambda: stack.sent_with("⚠️ the turn failed"), what="user"
|
||
)
|
||
assert "background" 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"
|