diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 2b8536062..bda137cf3 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -552,6 +552,39 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> raise last_exc +# --------------------------------------------------------------------------- +# Video cache utilities +# +# Same pattern as image/audio cache -- videos from platforms are downloaded +# here so the agent can reference them by local file path. +# --------------------------------------------------------------------------- + +VIDEO_CACHE_DIR = get_hermes_dir("cache/videos", "video_cache") + +SUPPORTED_VIDEO_TYPES = { + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", +} + + +def get_video_cache_dir() -> Path: + """Return the video cache directory, creating it if it doesn't exist.""" + VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + return VIDEO_CACHE_DIR + + +def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: + """Save raw video bytes to the cache and return the absolute file path.""" + cache_dir = get_video_cache_dir() + filename = f"video_{uuid.uuid4().hex[:12]}{ext}" + filepath = cache_dir / filename + filepath.write_bytes(data) + return str(filepath) + + # --------------------------------------------------------------------------- # Document cache utilities # diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index cf9a0a434..e849a03c7 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -71,8 +71,10 @@ from gateway.platforms.base import ( SendResult, cache_image_from_bytes, cache_audio_from_bytes, + cache_video_from_bytes, cache_document_from_bytes, resolve_proxy_url, + SUPPORTED_VIDEO_TYPES, SUPPORTED_DOCUMENT_TYPES, utf16_len, _prefix_within_utf16_limit, @@ -2628,6 +2630,23 @@ class TelegramAdapter(BasePlatformAdapter): except Exception as e: logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True) + elif msg.video: + try: + file_obj = await msg.video.get_file() + video_bytes = await file_obj.download_as_bytearray() + ext = ".mp4" + if getattr(file_obj, "file_path", None): + for candidate in SUPPORTED_VIDEO_TYPES: + if file_obj.file_path.lower().endswith(candidate): + ext = candidate + break + cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext) + event.media_urls = [cached_path] + event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")] + logger.info("[Telegram] Cached user video at %s", cached_path) + except Exception as e: + logger.warning("[Telegram] Failed to cache video: %s", e, exc_info=True) + # Download document files to cache for agent processing elif msg.document: doc = msg.document @@ -2644,6 +2663,21 @@ class TelegramAdapter(BasePlatformAdapter): mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} ext = mime_to_ext.get(doc.mime_type, "") + if not ext and doc.mime_type: + video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()} + ext = video_mime_to_ext.get(doc.mime_type, "") + + if ext in SUPPORTED_VIDEO_TYPES: + file_obj = await doc.get_file() + video_bytes = await file_obj.download_as_bytearray() + cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext) + event.media_urls = [cached_path] + event.media_types = [SUPPORTED_VIDEO_TYPES[ext]] + event.message_type = MessageType.VIDEO + logger.info("[Telegram] Cached user video document at %s", cached_path) + await self.handle_message(event) + return + # Check if supported if ext not in SUPPORTED_DOCUMENT_TYPES: supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys())) diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index 3a68139fa..d5564cbf4 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -23,6 +23,7 @@ from gateway.platforms.base import ( MessageType, SendResult, SUPPORTED_DOCUMENT_TYPES, + SUPPORTED_VIDEO_TYPES, ) @@ -117,6 +118,12 @@ def _make_update(msg): return update +def _make_video(file_obj=None): + video = MagicMock() + video.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b"video-bytes")) + return video + + # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @@ -132,10 +139,13 @@ def adapter(): @pytest.fixture(autouse=True) def _redirect_cache(tmp_path, monkeypatch): - """Point document cache to tmp_path so tests don't touch ~/.hermes.""" + """Point document/video cache to tmp_path so tests don't touch ~/.hermes.""" monkeypatch.setattr( "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache" ) + monkeypatch.setattr( + "gateway.platforms.base.VIDEO_CACHE_DIR", tmp_path / "video_cache" + ) # --------------------------------------------------------------------------- @@ -348,6 +358,37 @@ class TestDocumentDownloadBlock: adapter.handle_message.assert_called_once() +class TestVideoDownloadBlock: + @pytest.mark.asyncio + async def test_native_video_is_cached(self, adapter): + file_obj = _make_file_obj(b"fake-mp4") + file_obj.file_path = "videos/clip.mp4" + msg = _make_message() + msg.video = _make_video(file_obj) + update = _make_update(msg) + + await adapter._handle_media_message(update, MagicMock()) + event = adapter.handle_message.call_args[0][0] + assert event.message_type == MessageType.VIDEO + assert len(event.media_urls) == 1 + assert os.path.exists(event.media_urls[0]) + assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]] + + @pytest.mark.asyncio + async def test_mp4_document_is_treated_as_video(self, adapter): + file_obj = _make_file_obj(b"fake-mp4-doc") + doc = _make_document(file_name="good.mp4", mime_type="video/mp4", file_size=1024, file_obj=file_obj) + msg = _make_message(document=doc) + update = _make_update(msg) + + await adapter._handle_media_message(update, MagicMock()) + event = adapter.handle_message.call_args[0][0] + assert event.message_type == MessageType.VIDEO + assert len(event.media_urls) == 1 + assert os.path.exists(event.media_urls[0]) + assert event.media_types == [SUPPORTED_VIDEO_TYPES[".mp4"]] + + # --------------------------------------------------------------------------- # TestMediaGroups — media group (album) buffering # ---------------------------------------------------------------------------