From 5c3218eeed125c18bd7795047eee1156026457b5 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 8 Sep 2026 14:04:44 +0200 Subject: [PATCH] feat(frontends,conversations,telegram): the agent can hand a file to the human Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 4 + README.md | 3 +- src/beaver_gateway/app.py | 6 + src/beaver_gateway/conversations/messaging.py | 28 ++- src/beaver_gateway/conversations/tools.py | 48 +++++- src/beaver_gateway/frontends/base.py | 22 +++ src/beaver_gateway/frontends/files.py | 95 +++++++++++ .../frontends/telegram/frontend.py | 78 ++++++++- .../frontends/telegram/texts.py | 1 + src/beaver_gateway/settings.py | 4 + tests/test_telegram.py | 159 ++++++++++++++++++ 11 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 src/beaver_gateway/frontends/files.py diff --git a/.env.example b/.env.example index e67469d..a804af5 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,7 @@ RAYCAST_BEARER= # токен от @BotFather, свой user id - @userinfobot TELEGRAM_BOT_TOKEN= TELEGRAM_USER_ID= + +# Откуда тулза send_file имеет право брать файлы (через ':'). +# Папка входящих вложений разрешена всегда, её дописывает сам фронтенд. +SEND_FILE_ROOTS=/mnt/hole/shared diff --git a/README.md b/README.md index 95295d4..e4a3c36 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ is a full setup built on them. the envelope, the in-process gateway tools. - **jobs/** - cron, webhook and event jobs on pgqueuer, deferred injects, 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 `/anthropic/v1/messages`, MCP re-exposure at `/mcp/`, and `WebhookFrontend` for a window a setup declares itself - its own request diff --git a/src/beaver_gateway/app.py b/src/beaver_gateway/app.py index 255d897..78ef6df 100644 --- a/src/beaver_gateway/app.py +++ b/src/beaver_gateway/app.py @@ -6,6 +6,7 @@ import asyncio import functools import logging from contextlib import AsyncExitStack +from pathlib import Path from typing import TYPE_CHECKING, Any import psycopg @@ -195,6 +196,7 @@ async def run(gateway: Gateway, settings: Settings) -> None: scheduler=scheduler, public_url=gateway.public_url.rstrip("/") if gateway.public_url else None, scopes=scopes_with(fe.scope for fe in gateway.frontends), + send_file_roots=_roots(settings.send_file_roots), ) for fe in gateway.frontends: fe.configure(runtime) @@ -297,6 +299,10 @@ async def _serve_internal_mcp(app: Starlette, *, settings: Settings) -> None: 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: conversations: Conversations | None = None diff --git a/src/beaver_gateway/conversations/messaging.py b/src/beaver_gateway/conversations/messaging.py index c1729d3..f9b2928 100644 --- a/src/beaver_gateway/conversations/messaging.py +++ b/src/beaver_gateway/conversations/messaging.py @@ -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 @@ -6,6 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from beaver_gateway.conversations.turns import Turns +from beaver_gateway.frontends.files import SendFileError if TYPE_CHECKING: from datetime import datetime @@ -110,6 +111,31 @@ class Messaging(Turns): 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( self, conv: Conversation, diff --git a/src/beaver_gateway/conversations/tools.py b/src/beaver_gateway/conversations/tools.py index d7530e2..f5a3a12 100644 --- a/src/beaver_gateway/conversations/tools.py +++ b/src/beaver_gateway/conversations/tools.py @@ -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.kinds import as_kind +from beaver_gateway.frontends.files import AUTO, METHODS, SendFileError from beaver_gateway.security.redact import redact_data 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 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( @@ -232,6 +249,33 @@ def _tools(conversations: Conversations, key: str) -> list[SdkMcpTool[Any]]: ) 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( "close_chat", "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 _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]: diff --git a/src/beaver_gateway/frontends/base.py b/src/beaver_gateway/frontends/base.py index 3b90486..931a16e 100644 --- a/src/beaver_gateway/frontends/base.py +++ b/src/beaver_gateway/frontends/base.py @@ -10,10 +10,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from beaver_gateway.frontends.files import SendFileError from beaver_gateway.security.auth import BUILTIN_SCOPES if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping, Sequence + from pathlib import Path from starlette.types import ASGIApp @@ -58,6 +60,8 @@ class GatewayRuntime: """``Gateway.public_url``; ``None`` derives the origin from the request.""" scopes: frozenset[str] = BUILTIN_SCOPES """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): @@ -85,6 +89,8 @@ class Frontend(ABC): path: str | None = None landing: bool = False scope: str | None = None + sends_files: bool = False + """Whether ``send_file`` reaches a human through this frontend.""" @abstractmethod def configure(self, runtime: GatewayRuntime) -> None: ... @@ -104,3 +110,19 @@ class Frontend(ABC): async def mark_closed(self, conv: Conversation) -> bool: # noqa: ARG002 """Show in the window that the conversation is over (a renamed topic).""" 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 "" diff --git a/src/beaver_gateway/frontends/files.py b/src/beaver_gateway/frontends/files.py new file mode 100644 index 0000000..7a0290b --- /dev/null +++ b/src/beaver_gateway/frontends/files.py @@ -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}" diff --git a/src/beaver_gateway/frontends/telegram/frontend.py b/src/beaver_gateway/frontends/telegram/frontend.py index fab4931..623b63d 100644 --- a/src/beaver_gateway/frontends/telegram/frontend.py +++ b/src/beaver_gateway/frontends/telegram/frontend.py @@ -21,10 +21,13 @@ from zoneinfo import ZoneInfo from aiogram import Bot 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 ( BotCommand, CallbackQuery, + FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup, Message, @@ -33,10 +36,17 @@ from aiogram.types import ( ) 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.inbox import Inbox 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 if TYPE_CHECKING: @@ -53,6 +63,8 @@ _log = logging.getLogger("beaver_gateway.frontends.telegram") FRONTEND = "telegram" _DONE = "done" _COMMANDS = ("merge", "new", "chat", "status", "help", "start") +_CAPTION = 1024 +_NO_CAPTION = ("video_note",) @dataclass(frozen=True, slots=True) @@ -134,6 +146,7 @@ class _Album: class TelegramFrontend(Frontend): name = FRONTEND kinds = ("master", "branch") + sends_files = True def __init__( self, @@ -144,6 +157,8 @@ class TelegramFrontend(Frontend): branch_agent: str | None = None, chat_id: int | None = None, attachments: Attachments = EPHEMERAL, + send_files: SendFiles = DEFAULTS, + api_base_url: str | None = None, draft_interval: float = 0.7, master_topic: str = "🦫 General", queued_reaction: str = "👀", @@ -159,6 +174,8 @@ class TelegramFrontend(Frontend): self.master_agent = master_agent self.branch_agent = branch_agent self.attachments = attachments + self.send_files = send_files + self.api_base_url = api_base_url self.master_topic = master_topic self.draft_interval = draft_interval self.queued_reaction = queued_reaction @@ -187,7 +204,14 @@ class TelegramFrontend(Frontend): raise RuntimeError(msg) self._runtime = runtime 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( 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 ) + 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: me = await self.bot.get_me() _log.info( @@ -251,6 +285,44 @@ class TelegramFrontend(Frontend): self._topic_names[target[1]] = f"{prefix}{name}" 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 def bot(self) -> Bot: if self._bot is None: diff --git a/src/beaver_gateway/frontends/telegram/texts.py b/src/beaver_gateway/frontends/telegram/texts.py index 4619108..edf2116 100644 --- a/src/beaver_gateway/frontends/telegram/texts.py +++ b/src/beaver_gateway/frontends/telegram/texts.py @@ -26,6 +26,7 @@ TOOL_LABELS: dict[str, str] = { "mcp__gateway__say": "speaking", "mcp__gateway__schedule": "setting a reminder", "mcp__gateway__inject": "passing to another conversation", + "mcp__gateway__send_file": "sending a file", } diff --git a/src/beaver_gateway/settings.py b/src/beaver_gateway/settings.py index 5a6273d..9f3b4ca 100644 --- a/src/beaver_gateway/settings.py +++ b/src/beaver_gateway/settings.py @@ -57,6 +57,10 @@ class Settings(BaseSettings): """``Accept-Language`` sent to the Raycast API and the default locale for 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 = "" """Out-of-band token seed: ``name1:value1,name2:value2``. Layers alongside DB-issued tokens; used for first-run setup and recovery.""" diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 9831984..1dafdf6 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -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"] == "вот" 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"]