feat(frontends,conversations,telegram): the agent can hand a file to the human

This commit is contained in:
hh
2026-09-08 14:04:44 +02:00
parent 5eb9f435ec
commit c120953b02
11 changed files with 441 additions and 7 deletions
+4
View File
@@ -28,3 +28,7 @@ RAYCAST_BEARER=
# токен от @BotFather, свой user id - @userinfobot # токен от @BotFather, свой user id - @userinfobot
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_USER_ID= TELEGRAM_USER_ID=
# Откуда тулза send_file имеет право брать файлы (через ':').
# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд.
SEND_FILE_ROOTS=/mnt/hole/shared
+2 -1
View File
@@ -22,7 +22,8 @@ is a full setup built on them.
the envelope, the in-process gateway tools. the envelope, the in-process gateway tools.
- **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects, - **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects,
the subscription budget. the subscription budget.
- **frontends/** - the windows: Telegram (master = General, topic = branch), - **frontends/** - the windows: Telegram (master = General, topic = branch,
files out of the agent through `send_file`),
markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible
`/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and `/anthropic/v1/messages`, MCP re-exposure at `/mcp/<name>`, and
`WebhookFrontend` for a window a setup declares itself - its own request `WebhookFrontend` for a window a setup declares itself - its own request
+6
View File
@@ -6,6 +6,7 @@ import asyncio
import functools import functools
import logging import logging
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import psycopg import psycopg
@@ -195,6 +196,7 @@ async def run(gateway: Gateway, settings: Settings) -> None:
scheduler=scheduler, scheduler=scheduler,
public_url=gateway.public_url.rstrip("/") if gateway.public_url else None, public_url=gateway.public_url.rstrip("/") if gateway.public_url else None,
scopes=scopes_with(fe.scope for fe in gateway.frontends), scopes=scopes_with(fe.scope for fe in gateway.frontends),
send_file_roots=_roots(settings.send_file_roots),
) )
for fe in gateway.frontends: for fe in gateway.frontends:
fe.configure(runtime) fe.configure(runtime)
@@ -297,6 +299,10 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None:
await uvicorn.Server(config).serve() await uvicorn.Server(config).serve()
def _roots(value: str) -> tuple[Path, ...]:
return tuple(Path(p) for p in value.split(":") if p.strip())
class _LateConversations: class _LateConversations:
conversations: Conversations | None = None conversations: Conversations | None = None
+27 -1
View File
@@ -1,4 +1,4 @@
"""Putting words into a conversation: a message, an inject, ``say``, ``schedule``.""" """Putting words into a conversation: a message, an inject, ``say``, a file."""
from __future__ import annotations from __future__ import annotations
@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from beaver_gateway.conversations.turns import Turns from beaver_gateway.conversations.turns import Turns
from beaver_gateway.frontends.files import SendFileError
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import datetime from datetime import datetime
@@ -110,6 +111,31 @@ class Messaging(Turns):
turn_id=runner.turn_id if runner is not None else None, turn_id=runner.turn_id if runner is not None else None,
) )
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Hand a file to the human: this conversation's window, else the master's."""
for target in (conv, await self.open_master()):
if target is None:
continue
for fe in self._frontends:
if target.kind in fe.kinds and fe.sends_files:
note = await fe.send_file(
target, path, caption=caption, method=method
)
_log.info("send_file[%s]: %s", target.external_id, note)
return note
msg = (
"nowhere to send: no frontend that sends files shows this "
"conversation, and there is no open master"
)
raise SendFileError(msg)
def file_note(self) -> str:
"""What the ``send_file`` tool tells the model about roots and size."""
sender = next((f for f in self._frontends if f.sends_files), None)
return sender.file_note() if sender else "no frontend here sends files"
async def schedule( async def schedule(
self, self,
conv: Conversation, conv: Conversation,
+46 -2
View File
@@ -14,6 +14,7 @@ from claude_agent_sdk import create_sdk_mcp_server, tool
from beaver_gateway.conversations.injects import URGENCY from beaver_gateway.conversations.injects import URGENCY
from beaver_gateway.conversations.kinds import as_kind from beaver_gateway.conversations.kinds import as_kind
from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError
from beaver_gateway.security.redact import redact_data from beaver_gateway.security.redact import redact_data
URGENCY_HELP = ( URGENCY_HELP = (
@@ -42,7 +43,23 @@ SAY_IN_USER_TURN = (
"reply text reaches them by itself - put what you wanted to say into the " "reply text reaches them by itself - put what you wanted to say into the "
"reply instead of repeating it here" "reply instead of repeating it here"
) )
TOOL_NAMES = ("read_conversation", "spawn", "say", "schedule", "inject", "close_chat") TOOL_NAMES = (
"read_conversation",
"spawn",
"say",
"schedule",
"inject",
"close_chat",
"send_file",
)
SEND_FILE_HELP = (
"Send a file from disk to the human, into this conversation's window (the "
"master's if it has none): {note}. `method` is how Telegram renders it; "
"`auto` reads the extension - image is a photo, mp3/flac/m4a audio, ogg "
"voice, mp4/mov video, anything else a document. Photo and video are "
"recompressed, so pass `document` when the bytes must arrive untouched. "
"`caption` takes the same markdown as your replies."
)
def build_tool_server( def build_tool_server(
@@ -232,6 +249,33 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
) )
return _text(f"queued #{item.id}") return _text(f"queued #{item.id}")
@tool(
"send_file",
SEND_FILE_HELP.format(note=conversations.file_note()),
{
"type": "object",
"properties": {
"path": {"type": "string", "description": "absolute path on disk"},
"caption": {"type": "string"},
"method": {"type": "string", "enum": [AUTO, *METHODS], "default": AUTO},
},
"required": ["path"],
},
)
async def send_file(args: dict[str, Any]) -> dict[str, Any]:
conv = await current()
try:
return _text(
await conversations.send_file(
conv,
str(args["path"]),
caption=str(args.get("caption") or ""),
method=str(args.get("method") or AUTO),
)
)
except SendFileError as exc:
return _error(str(exc))
@tool( @tool(
"close_chat", "close_chat",
"Close this deep chat once the current reply is finished: the " "Close this deep chat once the current reply is finished: the "
@@ -248,7 +292,7 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]:
return _error(str(exc)) return _error(str(exc))
return _text("ok: the chat closes after this reply") return _text("ok: the chat closes after this reply")
return [read_conversation, spawn, say, schedule, inject, close_chat] return [read_conversation, spawn, say, schedule, inject, send_file, close_chat]
def _text(text: str) -> dict[str, Any]: def _text(text: str) -> dict[str, Any]:
+22
View File
@@ -10,10 +10,12 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from beaver_gateway.frontends.files import SendFileError
from beaver_gateway.security.auth import BUILTIN_SCOPES from beaver_gateway.security.auth import BUILTIN_SCOPES
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Awaitable, Callable, Mapping, Sequence
from pathlib import Path
from starlette.types import ASGIApp from starlette.types import ASGIApp
@@ -58,6 +60,8 @@ class GatewayRuntime:
"""``Gateway.public_url``; ``None`` derives the origin from the request.""" """``Gateway.public_url``; ``None`` derives the origin from the request."""
scopes: frozenset[str] = BUILTIN_SCOPES scopes: frozenset[str] = BUILTIN_SCOPES
"""Every token scope this gateway knows: the builtins plus each frontend's.""" """Every token scope this gateway knows: the builtins plus each frontend's."""
send_file_roots: tuple[Path, ...] = ()
"""Directories ``send_file`` may read, from ``SEND_FILE_ROOTS``."""
class Frontend(ABC): class Frontend(ABC):
@@ -85,6 +89,8 @@ class Frontend(ABC):
path: str | None = None path: str | None = None
landing: bool = False landing: bool = False
scope: str | None = None scope: str | None = None
sends_files: bool = False
"""Whether ``send_file`` reaches a human through this frontend."""
@abstractmethod @abstractmethod
def configure(self, runtime: GatewayRuntime) -> None: ... def configure(self, runtime: GatewayRuntime) -> None: ...
@@ -104,3 +110,19 @@ class Frontend(ABC):
async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002 async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002
"""Show in the window that the conversation is over (a renamed topic).""" """Show in the window that the conversation is over (a renamed topic)."""
return False return False
async def send_file(
self,
conv: Conversation, # noqa: ARG002
path: str, # noqa: ARG002
*,
caption: str = "", # noqa: ARG002
method: str = "auto", # noqa: ARG002
) -> str:
"""Put a file in this conversation's window; the answer goes to the model."""
msg = f"frontend {self.name!r} cannot send files"
raise SendFileError(msg)
def file_note(self) -> str:
"""Roots and ceiling, for the tool description the model reads."""
return ""
+95
View File
@@ -0,0 +1,95 @@
"""What an agent may hand to a human: an allowed path, a ceiling, a method.
The ceiling and the method names belong to the frontend's API; the check
lives here so every frontend refuses the same way, in words the model reads.
"""
from __future__ import annotations
import mimetypes
from dataclasses import dataclass, replace
from pathlib import Path
__all__ = ["AUTO", "DEFAULTS", "METHODS", "SendFileError", "SendFiles", "method_for"]
AUTO = "auto"
METHODS = ("document", "photo", "audio", "voice", "video", "video_note", "animation")
_BY_SUFFIX = {
".jpg": "photo",
".jpeg": "photo",
".png": "photo",
".webp": "photo",
".gif": "animation",
".mp3": "audio",
".flac": "audio",
".m4a": "audio",
".wav": "audio",
".aac": "audio",
".ogg": "voice",
".oga": "voice",
".opus": "voice",
".mp4": "video",
".m4v": "video",
".mov": "video",
}
_BY_TYPE = {"image": "photo", "audio": "audio", "video": "video"}
class SendFileError(Exception):
"""Why a file was not sent, worded for the model that asked."""
@dataclass(frozen=True, slots=True)
class SendFiles:
"""Which files a frontend accepts from an agent and how big they may be."""
roots: tuple[Path, ...] = ()
"""Directories a path must resolve inside; nothing outside is sendable."""
max_bytes: int = 50 * 1024 * 1024
"""Upload ceiling of the API behind the frontend; the Bot API gives 50 MB."""
def with_roots(self, *roots: Path) -> SendFiles:
return replace(self, roots=tuple(dict.fromkeys((*self.roots, *roots))))
@property
def note(self) -> str:
"""Roots and ceiling as one line for the tool description."""
where = ", ".join(str(r) for r in self.roots) or "none, so nothing is sendable"
return f"paths under {where}, up to {mb(self.max_bytes)} MB"
def resolve(self, raw: str) -> Path:
path = Path(raw.strip()).expanduser().resolve()
if not any(path.is_relative_to(r.expanduser().resolve()) for r in self.roots):
msg = f"{path} is outside the allowed roots ({self.note}); copy it there"
raise SendFileError(msg)
if not path.is_file():
msg = f"{path} is not a file"
raise SendFileError(msg)
size = path.stat().st_size
if size > self.max_bytes:
msg = (
f"{path.name} is {mb(size)} MB, over the {mb(self.max_bytes)} MB limit"
)
raise SendFileError(msg)
return path
DEFAULTS = SendFiles()
def method_for(path: Path, method: str = AUTO) -> str:
if method and method != AUTO:
if method not in METHODS:
msg = f"unknown method {method!r}; one of: {', '.join((AUTO, *METHODS))}"
raise SendFileError(msg)
return method
guessed = _BY_SUFFIX.get(path.suffix.lower())
if guessed:
return guessed
mime, _ = mimetypes.guess_type(path.name)
return _BY_TYPE.get(mime.split("/")[0], "document") if mime else "document"
def mb(size: int) -> str:
return f"{round(size / 1024 / 1024, 2):g}"
@@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.exceptions import TelegramAPIError from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.exceptions import TelegramAPIError, TelegramNetworkError
from aiogram.types import ( from aiogram.types import (
BotCommand, BotCommand,
CallbackQuery, CallbackQuery,
FSInputFile,
InlineKeyboardButton, InlineKeyboardButton,
InlineKeyboardMarkup, InlineKeyboardMarkup,
Message, Message,
@@ -33,10 +36,17 @@ from aiogram.types import (
) )
from beaver_gateway.frontends.base import Frontend from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.files import (
DEFAULTS,
SendFileError,
SendFiles,
mb,
method_for,
)
from beaver_gateway.frontends.telegram.drafts import Draft from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.inbox import Inbox from beaver_gateway.frontends.telegram.inbox import Inbox
from beaver_gateway.frontends.telegram.outbox import Outbox from beaver_gateway.frontends.telegram.outbox import Outbox
from beaver_gateway.frontends.telegram.render import chunks, status_label from beaver_gateway.frontends.telegram.render import chunks, status_label, to_html
from beaver_gateway.frontends.telegram.texts import TelegramTexts from beaver_gateway.frontends.telegram.texts import TelegramTexts
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -53,6 +63,8 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram")
FRONTEND = "telegram" FRONTEND = "telegram"
_DONE = "done" _DONE = "done"
_COMMANDS = ("merge", "new", "chat", "status", "help", "start") _COMMANDS = ("merge", "new", "chat", "status", "help", "start")
_CAPTION = 1024
_NO_CAPTION = ("video_note",)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -134,6 +146,7 @@ class _Album:
class TelegramFrontend(Frontend): class TelegramFrontend(Frontend):
name = FRONTEND name = FRONTEND
kinds = ("master", "branch") kinds = ("master", "branch")
sends_files = True
def __init__( def __init__(
self, self,
@@ -144,6 +157,8 @@ class TelegramFrontend(Frontend):
branch_agent: str | None = None, branch_agent: str | None = None,
chat_id: int | None = None, chat_id: int | None = None,
attachments: Attachments = EPHEMERAL, attachments: Attachments = EPHEMERAL,
send_files: SendFiles = DEFAULTS,
api_base_url: str | None = None,
draft_interval: float = 0.7, draft_interval: float = 0.7,
master_topic: str = "🦫 General", master_topic: str = "🦫 General",
queued_reaction: str = "👀", queued_reaction: str = "👀",
@@ -159,6 +174,8 @@ class TelegramFrontend(Frontend):
self.master_agent = master_agent self.master_agent = master_agent
self.branch_agent = branch_agent self.branch_agent = branch_agent
self.attachments = attachments self.attachments = attachments
self.send_files = send_files
self.api_base_url = api_base_url
self.master_topic = master_topic self.master_topic = master_topic
self.draft_interval = draft_interval self.draft_interval = draft_interval
self.queued_reaction = queued_reaction self.queued_reaction = queued_reaction
@@ -187,7 +204,14 @@ class TelegramFrontend(Frontend):
raise RuntimeError(msg) raise RuntimeError(msg)
self._runtime = runtime self._runtime = runtime
if self._bot is None: if self._bot is None:
self._bot = Bot(self._token, default=DefaultBotProperties(parse_mode=None)) self._bot = Bot(
self._token,
session=self._session(),
default=DefaultBotProperties(parse_mode=None),
)
self.send_files = self.send_files.with_roots(
*runtime.send_file_roots, self.attachments.root
)
self._inbox = Inbox( self._inbox = Inbox(
runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout runtime.db, self._bot, handler=self._handle, poll_timeout=self.poll_timeout
) )
@@ -195,6 +219,16 @@ class TelegramFrontend(Frontend):
runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff runtime.db, self._bot, bus=runtime.bus, backoff=self.outbox_backoff
) )
def _session(self) -> AiohttpSession | None:
"""A local Bot API server instead of api.telegram.org (2 GB uploads)."""
if not self.api_base_url:
return None
return AiohttpSession(
api=TelegramAPIServer.from_base(
self.api_base_url.rstrip("/"), is_local=True
)
)
async def serve(self) -> None: async def serve(self) -> None:
me = await self.bot.get_me() me = await self.bot.get_me()
_log.info( _log.info(
@@ -251,6 +285,44 @@ class TelegramFrontend(Frontend):
self._topic_names[target[1]] = f"{prefix}{name}" self._topic_names[target[1]] = f"{prefix}{name}"
return True return True
def file_note(self) -> str:
return self.send_files.note
async def send_file(
self, conv: Conversation, path: str, *, caption: str = "", method: str = "auto"
) -> str:
"""Upload the file into this conversation's topic, caption and all."""
target = await self._target_of(conv)
if target is None:
msg = f"conversation {conv.external_id} has no Telegram window"
raise SendFileError(msg)
file = self.send_files.resolve(path)
how = method_for(file, method)
rest = caption.strip()
payload: dict[str, Any] = {
"message_thread_id": target[1],
how: FSInputFile(file),
}
if how == "audio":
payload["title"] = file.stem
if rest and how not in _NO_CAPTION and len(rest) <= _CAPTION:
payload |= {"caption": to_html(rest), "parse_mode": "HTML"}
rest = ""
try:
await getattr(self.bot, f"send_{how}")(target[0], **payload)
except TelegramNetworkError as exc:
msg = f"Telegram did not answer while sending {file.name}: {exc}"
raise SendFileError(msg) from exc
except TelegramAPIError as exc:
msg = f"Telegram refused {file.name} as {how}: {exc}"
raise SendFileError(msg) from exc
except (OSError, TimeoutError) as exc:
msg = f"sending {file.name} failed: {exc}"
raise SendFileError(msg) from exc
if rest:
await self._deliver(conv, rest)
return f"sent {file.name} as {how}, {mb(file.stat().st_size)} MB"
@property @property
def bot(self) -> Bot: def bot(self) -> Bot:
if self._bot is None: if self._bot is None:
@@ -26,6 +26,7 @@ TOOL_LABELS: dict[str, str] = {
"mcp__gateway__say": "speaking", "mcp__gateway__say": "speaking",
"mcp__gateway__schedule": "setting a reminder", "mcp__gateway__schedule": "setting a reminder",
"mcp__gateway__inject": "passing to another conversation", "mcp__gateway__inject": "passing to another conversation",
"mcp__gateway__send_file": "sending a file",
} }
+4
View File
@@ -57,6 +57,10 @@ class Settings(BaseSettings):
"""``Accept-Language`` sent to the Raycast API and the default locale for """``Accept-Language`` sent to the Raycast API and the default locale for
auto ``UserPreferences``. One value per gateway (one shared Client).""" auto ``UserPreferences``. One value per gateway (one shared Client)."""
send_file_roots: str = "/mnt/hole/shared"
"""Directories the ``send_file`` tool may read, separated by ``:``. A
frontend always adds the inbox its own attachments land in."""
bootstrap_tokens: str = "" bootstrap_tokens: str = ""
"""Out-of-band token seed: ``name1:value1,name2:value2``. Layers """Out-of-band token seed: ``name1:value1,name2:value2``. Layers
alongside DB-issued tokens; used for first-run setup and recovery.""" alongside DB-issued tokens; used for first-run setup and recovery."""
+159
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import base64 import base64
import contextlib import contextlib
import functools
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
@@ -14,7 +15,9 @@ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.app import McpRegistry from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries 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.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 import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.render import ( from beaver_gateway.frontends.telegram.render import (
@@ -36,6 +39,8 @@ class FakeBot:
def __init__(self) -> None: def __init__(self) -> None:
self.updates: list[dict[str, Any]] = [] self.updates: list[dict[str, Any]] = []
self.sent: 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.drafts: list[dict[str, Any]] = []
self.edits: list[dict[str, Any]] = [] self.edits: list[dict[str, Any]] = []
self.topics: list[str] = [] self.topics: list[str] = []
@@ -132,6 +137,21 @@ class FakeBot:
async def download(self, file_id, destination=None) -> None: async def download(self, file_id, destination=None) -> None:
Path(destination).write_bytes(b"data") 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 # helpers for tests
def push(self, payload: dict[str, Any]) -> None: def push(self, payload: dict[str, Any]) -> None:
payload["update_id"] = 1000 + len(self.updates) 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") await stack.until(lambda: stack.sent_with("ok:Красный"), what="answer as message")
row = await stack.world.conversations.get(master.external_id) row = await stack.world.conversations.get(master.external_id)
assert row.flags.get("ask") is None 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"]