feat(frontends,conversations,telegram): the agent can hand a file to the human
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import functools
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -14,7 +15,9 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
||||
|
||||
from beaver_gateway.app import McpRegistry
|
||||
from beaver_gateway.backends.transcript import build_entries
|
||||
from beaver_gateway.conversations.tools import _tools
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
|
||||
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
|
||||
from beaver_gateway.frontends.telegram.drafts import Draft
|
||||
from beaver_gateway.frontends.telegram.render import (
|
||||
@@ -36,6 +39,8 @@ class FakeBot:
|
||||
def __init__(self) -> None:
|
||||
self.updates: list[dict[str, Any]] = []
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
self.files: list[dict[str, Any]] = []
|
||||
self.reject_files = False
|
||||
self.drafts: list[dict[str, Any]] = []
|
||||
self.edits: list[dict[str, Any]] = []
|
||||
self.topics: list[str] = []
|
||||
@@ -132,6 +137,21 @@ class FakeBot:
|
||||
async def download(self, file_id, destination=None) -> None:
|
||||
Path(destination).write_bytes(b"data")
|
||||
|
||||
async def _upload(self, method: str, chat_id: int, **kwargs: Any) -> Any:
|
||||
if self.reject_files:
|
||||
raise TelegramBadRequest(
|
||||
method=SendMessage(chat_id=0, text="x"),
|
||||
message="Bad Request: PHOTO_INVALID_DIMENSIONS",
|
||||
)
|
||||
self._message_id += 1
|
||||
self.files.append({"method": method, "chat_id": chat_id, **kwargs})
|
||||
return SimpleNamespace(message_id=self._message_id)
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
if name.startswith("send_"):
|
||||
return functools.partial(self._upload, name.removeprefix("send_"))
|
||||
raise AttributeError(name)
|
||||
|
||||
# helpers for tests
|
||||
def push(self, payload: dict[str, Any]) -> None:
|
||||
payload["update_id"] = 1000 + len(self.updates)
|
||||
@@ -1035,3 +1055,142 @@ async def test_question_survives_a_gateway_restart(stack: Stack) -> None:
|
||||
await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
|
||||
row = await stack.world.conversations.get(master.external_id)
|
||||
assert row.flags.get("ask") is None
|
||||
|
||||
|
||||
def _sendable(name: str, body: bytes = b"x") -> tuple[Path, Path]:
|
||||
root = Path(tempfile.mkdtemp(prefix="beaver-send-")).resolve()
|
||||
file = root / name
|
||||
file.write_bytes(body)
|
||||
return root, file
|
||||
|
||||
|
||||
def test_send_files_takes_only_paths_inside_its_roots() -> None:
|
||||
root, note = _sendable("note.txt")
|
||||
outside = Path(tempfile.mkdtemp(prefix="beaver-out-")).resolve()
|
||||
(outside / "secret.txt").write_bytes(b"y")
|
||||
(root / "escape.txt").symlink_to(outside / "secret.txt")
|
||||
files = SendFiles(roots=(root,))
|
||||
|
||||
assert files.resolve(f"{root}/../{root.name}/./note.txt") == note
|
||||
|
||||
for bad in (
|
||||
str(outside / "secret.txt"),
|
||||
f"{root}/../{outside.name}/secret.txt",
|
||||
str(root / "escape.txt"),
|
||||
):
|
||||
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||
files.resolve(bad)
|
||||
with pytest.raises(SendFileError, match="is not a file"):
|
||||
files.resolve(str(root / "gone.txt"))
|
||||
with pytest.raises(SendFileError, match="outside the allowed roots"):
|
||||
SendFiles().resolve(str(note))
|
||||
|
||||
|
||||
def test_send_files_stops_a_file_over_the_limit() -> None:
|
||||
root, big = _sendable("big.bin", b"0" * (2 * 1024 * 1024))
|
||||
files = SendFiles(roots=(root,), max_bytes=1024 * 1024)
|
||||
with pytest.raises(SendFileError, match="big.bin is 2 MB, over the 1 MB limit"):
|
||||
files.resolve(str(big))
|
||||
assert SendFiles(roots=(root,), max_bytes=4 * 1024 * 1024).resolve(str(big)) == big
|
||||
assert files.note == f"paths under {root}, up to 1 MB"
|
||||
|
||||
|
||||
def test_method_follows_the_extension_unless_it_is_given() -> None:
|
||||
picked = {
|
||||
name: method_for(Path(name))
|
||||
for name in ("a.JPG", "b.gif", "c.mp3", "d.ogg", "e.mov", "f.zip", "g.svg")
|
||||
}
|
||||
assert picked == {
|
||||
"a.JPG": "photo",
|
||||
"b.gif": "animation",
|
||||
"c.mp3": "audio",
|
||||
"d.ogg": "voice",
|
||||
"e.mov": "video",
|
||||
"f.zip": "document",
|
||||
"g.svg": "photo",
|
||||
}
|
||||
assert method_for(Path("a.jpg"), "document") == "document"
|
||||
with pytest.raises(SendFileError, match="unknown method 'sticker'"):
|
||||
method_for(Path("a.jpg"), "sticker")
|
||||
|
||||
|
||||
async def _send(stack: Stack, conv, **args: Any) -> dict[str, Any]:
|
||||
tool = next(
|
||||
t
|
||||
for t in _tools(stack.world.conversations, conv.external_id)
|
||||
if t.name == "send_file"
|
||||
)
|
||||
return await tool.handler(args)
|
||||
|
||||
|
||||
async def test_send_file_lands_in_the_topic_of_the_conversation(stack: Stack) -> None:
|
||||
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"
|
||||
)
|
||||
root, shot = _sendable("график.png", b"png")
|
||||
stack.tg.send_files = SendFiles(roots=(root,))
|
||||
|
||||
result = await _send(stack, branch, path=str(shot), caption="**вот**")
|
||||
|
||||
assert result["content"][0]["text"].startswith("sent график.png as photo")
|
||||
(sent,) = stack.bot.files
|
||||
assert sent["method"] == "photo"
|
||||
assert sent["message_thread_id"] == 11
|
||||
assert sent["photo"].path == shot
|
||||
assert sent["caption"] == "<b>вот</b>" and sent["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
async def test_long_caption_follows_the_file_as_a_message(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
|
||||
)
|
||||
root, doc = _sendable("отчёт.pdf")
|
||||
stack.tg.send_files = SendFiles(roots=(root,))
|
||||
|
||||
await _send(stack, master, path=str(doc), caption="долго " * 300)
|
||||
|
||||
(sent,) = stack.bot.files
|
||||
assert sent["method"] == "document" and "caption" not in sent
|
||||
await stack.until(lambda: stack.sent_with("долго"), what="caption message")
|
||||
|
||||
|
||||
async def test_send_file_without_a_window_falls_back_to_the_master(
|
||||
stack: Stack,
|
||||
) -> None:
|
||||
stack.bot.message("hi")
|
||||
await stack.until(lambda: stack.sent_with("hi"), what="reply")
|
||||
job = await stack.world.conversations.spawn(
|
||||
kind="job", agent="a", seed="brief", text="job", parent=None, origin="test"
|
||||
)
|
||||
root, song = _sendable("Пикник.mp3")
|
||||
stack.tg.send_files = SendFiles(roots=(root,))
|
||||
|
||||
await _send(stack, job, path=str(song))
|
||||
|
||||
(sent,) = stack.bot.files
|
||||
assert sent["method"] == "audio" and sent["title"] == "Пикник"
|
||||
assert (sent["chat_id"], sent["message_thread_id"]) == (USER, 901)
|
||||
|
||||
|
||||
async def test_a_refused_upload_comes_back_to_the_agent(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
|
||||
)
|
||||
root, shot = _sendable("bad.png")
|
||||
stack.tg.send_files = SendFiles(roots=(root,))
|
||||
stack.bot.reject_files = True
|
||||
|
||||
result = await _send(stack, master, path=str(shot))
|
||||
|
||||
assert result["is_error"]
|
||||
assert "Telegram refused bad.png as photo" in result["content"][0]["text"]
|
||||
|
||||
outside = await _send(stack, master, path="/etc/passwd")
|
||||
assert outside["is_error"]
|
||||
assert "outside the allowed roots" in outside["content"][0]["text"]
|
||||
|
||||
Reference in New Issue
Block a user