feat(channels): add video support for Telegram and WebSocket
Telegram previously sent all video files as documents via send_document, so users saw a file icon instead of an inline player. WebSocket only accepted image MIME types, rejecting video uploads entirely. Telegram: - Recognize video extensions (mp4/mov/avi/mkv/webm/3gp) in _get_media_type - Route videos through send_video with supports_streaming=True - Add VIDEO/VIDEO_NOTE/ANIMATION to inbound message filters - Add video MIME mappings to _get_extension - Fix: local file sends now use _call_with_retry (previously no retry) WebSocket: - Expand upload MIME whitelist with video/mp4, video/webm, video/quicktime - Add per-type size limits (_MAX_VIDEO_BYTES=20MB, _MAX_VIDEOS_PER_MESSAGE=1) - Expand media serving endpoint to serve video with correct Content-Type Agent: - Add "video" to message tool media parameter description - Add .mp4 example to identity.md system prompt Made-with: Cursor
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user