diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index ea0598a1..ee78df46 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -15,7 +15,7 @@ from nanobot.bus.events import OutboundMessage chat_id=StringSchema("Optional: target chat/user ID"), media=ArraySchema( StringSchema(""), - description="Optional: list of file paths to attach (images, audio, documents)", + description="Optional: list of file paths to attach (images, video, audio, documents)", ), buttons=ArraySchema( ArraySchema(StringSchema("Button label")), diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 38c3fe89..1e392dd1 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -7,6 +7,7 @@ import re import time import unicodedata from dataclasses import dataclass +from pathlib import Path from typing import Any, Literal from loguru import logger @@ -357,10 +358,12 @@ class TelegramChannel(BaseChannel): ) self._app.add_handler(MessageHandler(filters.Regex(r"^/help(?:@\w+)?$"), self._on_help)) - # Add message handler for text, photos, voice, documents, and locations + # Add message handler for text, photos, video, voice, documents, and locations self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL | filters.LOCATION) + (filters.TEXT | filters.PHOTO | filters.VIDEO | filters.VIDEO_NOTE + | filters.ANIMATION | filters.VOICE | filters.AUDIO + | filters.Document.ALL | filters.LOCATION) & ~filters.COMMAND, self._on_message ) @@ -429,6 +432,8 @@ class TelegramChannel(BaseChannel): ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" if ext in ("jpg", "jpeg", "png", "gif", "webp"): return "photo" + if ext in ("mp4", "mov", "avi", "mkv", "webm", "3gp"): + return "video" if ext == "ogg": return "voice" if ext in ("mp3", "m4a", "wav", "aac"): @@ -481,10 +486,19 @@ class TelegramChannel(BaseChannel): media_type = self._get_media_type(media_path) sender = { "photo": self._app.bot.send_photo, + "video": self._app.bot.send_video, "voice": self._app.bot.send_voice, "audio": self._app.bot.send_audio, }.get(media_type, self._app.bot.send_document) - param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" + param = { + "photo": "photo", + "video": "video", + "voice": "voice", + "audio": "audio", + }.get(media_type, "document") + extra: dict[str, Any] = {} + if media_type == "video": + extra["supports_streaming"] = True # Telegram Bot API accepts HTTP(S) URLs directly for media params. if self._is_remote_media_url(media_path): @@ -497,16 +511,19 @@ class TelegramChannel(BaseChannel): **{param: media_path}, reply_parameters=reply_params, **thread_kwargs, + **extra, ) continue - with open(media_path, "rb") as f: - await sender( - chat_id=chat_id, - **{param: f}, - reply_parameters=reply_params, - **thread_kwargs, - ) + media_bytes = Path(media_path).read_bytes() + await self._call_with_retry( + sender, + chat_id=chat_id, + **{param: media_bytes}, + reply_parameters=reply_params, + **thread_kwargs, + **extra, + ) except Exception as e: filename = media_path.rsplit("/", 1)[-1] logger.error("Failed to send media {}: {}", media_path, e) @@ -1184,18 +1201,19 @@ class TelegramChannel(BaseChannel): if mime_type: ext_map = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", + "image/webp": ".webp", "audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", + "video/mp4": ".mp4", "video/quicktime": ".mov", "video/webm": ".webm", + "video/x-matroska": ".mkv", "video/3gpp": ".3gp", } if mime_type in ext_map: return ext_map[mime_type] - type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "video": ".mp4", "file": ""} if ext := type_map.get(media_type, ""): return ext if filename: - from pathlib import Path - return "".join(Path(filename).suffixes) return "" diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 47c79e87..b0119178 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -218,12 +218,14 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None: return data -# Per-message image limits. The server-side guard is a touch looser than the +# Per-message media limits. The server-side guard is a touch looser than the # client's ``Worker`` normalization target (6 MB) — tolerate client slop, but # still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES`` # which fits comfortably inside ``max_message_bytes``. _MAX_IMAGES_PER_MESSAGE = 4 _MAX_IMAGE_BYTES = 8 * 1024 * 1024 +_MAX_VIDEOS_PER_MESSAGE = 1 +_MAX_VIDEO_BYTES = 20 * 1024 * 1024 # Image MIME whitelist — matches the Composer's ``accept`` list. SVG is # explicitly excluded to avoid the XSS surface inside embedded scripts. @@ -234,6 +236,14 @@ _IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({ "image/gif", }) +_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({ + "video/mp4", + "video/webm", + "video/quicktime", +}) + +_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED + _DATA_URL_MIME_RE = re.compile(r"^data:([^;]+);base64,", re.DOTALL) @@ -339,6 +349,9 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({ "image/jpeg", "image/webp", "image/gif", + "video/mp4", + "video/webm", + "video/quicktime", }) @@ -945,14 +958,25 @@ class WebSocketChannel(BaseChannel): Returns ``(paths, None)`` on success or ``([], reason)`` on the first failure — the caller is expected to surface ``reason`` to the client and skip publishing so no half-formed message ever reaches the agent. - On failure, any images already written to disk earlier in the same + On failure, any files already written to disk earlier in the same call are unlinked so partial ingress doesn't leak orphan files. ``reason`` is a short, stable token suitable for UI localization. Shape: ``list[{"data_url": str, "name"?: str | None}]``. """ - if len(media) > _MAX_IMAGES_PER_MESSAGE: + image_count = 0 + video_count = 0 + for item in media: + mime = _extract_data_url_mime(item.get("data_url", "")) if isinstance(item, dict) else None + if mime in _VIDEO_MIME_ALLOWED: + video_count += 1 + elif mime in _IMAGE_MIME_ALLOWED: + image_count += 1 + if image_count > _MAX_IMAGES_PER_MESSAGE: return [], "too_many_images" + if video_count > _MAX_VIDEOS_PER_MESSAGE: + return [], "too_many_videos" + media_dir = get_media_dir("websocket") paths: list[str] = [] @@ -975,11 +999,13 @@ class WebSocketChannel(BaseChannel): mime = _extract_data_url_mime(data_url) if mime is None: return _abort("decode") - if mime not in _IMAGE_MIME_ALLOWED: + if mime not in _UPLOAD_MIME_ALLOWED: return _abort("mime") + is_video = mime in _VIDEO_MIME_ALLOWED + max_bytes = _MAX_VIDEO_BYTES if is_video else _MAX_IMAGE_BYTES try: saved = save_base64_data_url( - data_url, media_dir, max_bytes=_MAX_IMAGE_BYTES, + data_url, media_dir, max_bytes=max_bytes, ) except FileSizeExceeded: return _abort("size") diff --git a/nanobot/templates/agent/identity.md b/nanobot/templates/agent/identity.md index 31f3d0d2..a53be709 100644 --- a/nanobot/templates/agent/identity.md +++ b/nanobot/templates/agent/identity.md @@ -29,4 +29,4 @@ Output is rendered in a terminal. Avoid markdown headings and tables. Use plain {% include 'agent/_snippets/untrusted_content.md' %} Reply directly with text for conversations. Only use the 'message' tool to send to a specific chat channel. -IMPORTANT: To send files (images, documents, audio, video) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the file", media=["/path/to/file.png"]) +IMPORTANT: To send files (images, video, audio, documents) to the user, you MUST call the 'message' tool with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Examples: message(content="Here is the image", media=["/path/to/file.png"]) or message(content="Here is the video", media=["/path/to/video.mp4"]) diff --git a/tests/channels/test_telegram_channel.py b/tests/channels/test_telegram_channel.py index 175844b2..1fbfe243 100644 --- a/tests/channels/test_telegram_channel.py +++ b/tests/channels/test_telegram_channel.py @@ -59,6 +59,9 @@ class _FakeBot: async def send_photo(self, **kwargs) -> None: self.sent_media.append({"kind": "photo", **kwargs}) + async def send_video(self, **kwargs) -> None: + self.sent_media.append({"kind": "video", **kwargs}) + async def send_voice(self, **kwargs) -> None: self.sent_media.append({"kind": "voice", **kwargs})