{"repo":"NousResearch/hermes-agent","public_id":"4204","display_label":"Video attachment caching and handling across platforms","summary":"These issues and pull requests concern inbound video attachments not being cached or accepted on Discord and Telegram, along with proposed fixes for both platforms including mp4 upload support.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":13009},"resolution":{"state":"partially_resolved","by":"pr-13009"},"freshness":{"state":"stale","last_verified_at":"2026-04-20T12:10:23+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":4204,"pr_number":13009,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4204,"title":"Telegram inbound videos are not cached and .mp4 file uploads are rejected as unsupported documents","url":null,"state":"closed"},{"kind":"issue","number":13149,"title":"Discord: inbound video attachments are rejected instead of cached","url":null,"state":"closed"},{"kind":"pr","number":4205,"title":"fix(telegram): cache inbound videos and accept mp4 uploads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#13009","number":13009}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":13009,"title":"fix(telegram): cache inbound videos and accept mp4 uploads","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/telegram.py","scripts/release.py","tests/gateway/test_telegram_documents.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -552,6 +552,39 @@ async def cache_audio_from_url(url: str, ext: str = \".ogg\", retries: int = 2) ->\n     raise last_exc\n \n \n+# ---------------------------------------------------------------------------\n+# Video cache utilities\n+#\n+# Same pattern as image/audio cache -- videos from platforms are downloaded\n+# here so the agent can reference them by local file path.\n+# ---------------------------------------------------------------------------\n+\n+VIDEO_CACHE_DIR = get_hermes_dir(\"cache/videos\", \"video_cache\")\n+\n+SUPPORTED_VIDEO_TYPES = {\n+    \".mp4\": \"video/mp4\",\n+    \".mov\": \"video/quicktime\",\n+    \".webm\": \"video/webm\",\n+    \".mkv\": \"video/x-matroska\",\n+    \".avi\": \"video/x-msvideo\",\n+}\n+\n+\n+def get_video_cache_dir() -> Path:\n+    \"\"\"Return the video cache directory, creating it if it doesn't exist.\"\"\"\n+    VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True)\n+    return VIDEO_CACHE_DIR\n+\n+\n+def cache_video_from_bytes(data: bytes, ext: str = \".mp4\") -> str:\n+    \"\"\"Save raw video bytes to the cache and return the absolute file path.\"\"\"\n+    cache_dir = get_video_cache_dir()\n+    filename = f\"video_{uuid.uuid4().hex[:12]}{ext}\"\n+    filepath = cache_dir / filename\n+    filepath.write_bytes(data)\n+    return str(filepath)\n+\n+\n # ---------------------------------------------------------------------------\n # Document cache utilities\n #"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -71,8 +71,10 @@ class _MockContextTypes:\n     SendResult,\n     cache_image_from_bytes,\n     cache_audio_from_bytes,\n+    cache_video_from_bytes,\n     cache_document_from_bytes,\n     resolve_proxy_url,\n+    SUPPORTED_VIDEO_TYPES,\n     SUPPORTED_DOCUMENT_TYPES,\n     utf16_len,\n     _prefix_within_utf16_limit,","@@ -2628,6 +2630,23 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n             except Exception as e:\n                 logger.warning(\"[Telegram] Failed to cache audio: %s\", e, exc_info=True)\n \n+        elif msg.video:\n+            try:\n+                file_obj = await msg.video.get_file()\n+                video_bytes = await file_obj.download_as_bytearray()\n+                ext = \".mp4\"\n+                if getattr(file_obj, \"file_path\", None):\n+                    for candidate in SUPPORTED_VIDEO_TYPES:\n+                        if file_obj.file_path.lower().endswith(candidate):\n+                            ext = candidate\n+                            break\n+                cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)\n+                event.media_urls = [cached_path]\n+                event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, \"video/mp4\")]\n+                logger.info(\"[Telegram] Cached user video at %s\", cached_path)\n+            except Exception as e:\n+                logger.warning(\"[Telegram] Failed to cache video: %s\", e, exc_info=True)\n+\n         # Download document files to cache for agent processing\n         elif msg.document:\n             doc = msg.document","@@ -2644,6 +2663,21 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n                     mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}\n                     ext = mime_to_ext.get(doc.mime_type, \"\")\n \n+                if not ext and doc.mime_type:\n+                    video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}\n+                    ext = video_mime_to_ext.get(doc.mime_type, \"\")\n+\n+                if ext in SUPPORTED_VIDEO_TYPES:\n+                    file_obj = await doc.get_file()\n+                    video_bytes = await file_obj.download_as_bytearray()\n+                    cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)\n+                    event.media_urls = [cached_path]\n+                    event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]\n+                    event.message_type = MessageType.VIDEO\n+                    logger.info(\"[Telegram] Cached user video document at %s\", cached_path)\n+                    await self.handle_message(event)\n+                    return\n+\n                 # Check if supported\n                 if ext not in SUPPORTED_DOCUMENT_TYPES:\n                     supported_list = \", \".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))"]},{"file":"scripts/release.py","hunks":["@@ -77,6 +77,7 @@\n     \"[redacted:email]\": \"yyq4193\",\n     \"[redacted:email]\": \"Asunfly\",\n     \"[redacted:email]\": \"honghua\",\n+    \"[redacted:email]\": \"jplew\",\n     \"[redacted:email]\": \"nish3451\",\n     \"[redacted:email]\": \"Mibayy\",\n     \"[redacted:email]\": \"sgaofen\","]},{"file":"tests/gateway/test_telegram_documents.py","hunks":["@@ -23,6 +23,7 @@\n     MessageType,\n     SendResult,\n     SUPPORTED_DOCUMENT_TYPES,\n+    SUPPORTED_VIDEO_TYPES,\n )\n \n ","@@ -117,6 +118,12 @@ def _make_update(msg):\n     return update\n \n \n+def _make_video(file_obj=None):\n+    video = MagicMock()\n+    video.get_file = AsyncMock(return_value=file_obj or _make_file_obj(b\"video-bytes\"))\n+    return video\n+\n+\n # ---------------------------------------------------------------------------\n # Fixtures\n # ---------------------------------------------------------------------------","@@ -132,10 +139,13 @@ def adapter():\n \n @pytest.fixture(autouse=True)\n def _redirect_cache(tmp_path, monkeypatch):\n-    \"\"\"Point document cache to tmp_path so tests don't touch ~/.hermes.\"\"\"\n+    \"\"\"Point document/video cache to tmp_path so tests don't touch ~/.hermes.\"\"\"\n     monkeypatch.setattr(\n         \"gateway.platforms.base.DOCUMENT_CACHE_DIR\", tmp_path / \"doc_cache\"\n     )\n+    monkeypatch.setattr(\n+        \"gateway.platforms.base.VIDEO_CACHE_DIR\", tmp_path / \"video_cache\"\n+    )\n \n \n # ---------------------------------------------------------------------------","@@ -348,6 +358,37 @@ async def test_download_exception_handled(self, adapter):\n         adapter.handle_message.assert_called_once()\n \n \n+class TestVideoDownloadBlock:\n+    @pytest.mark.asyncio\n+    async def test_native_video_is_cached(self, adapter):\n+        file_obj = _make_file_obj(b\"fake-mp4\")\n+        file_obj.file_path = \"videos/clip.mp4\"\n+        msg = _make_message()\n+        msg.video = _make_video(file_obj)\n+        update = _make_update(msg)\n+\n+        await adapter._handle_media_message(update, MagicMock())\n+        event = adapter.handle_message.call_args[0][0]\n+        assert event.message_type == MessageType.VIDEO\n+        assert len(event.media_urls) == 1\n+        assert os.path.exists(event.media_urls[0])\n+        assert event.media_types == [SUPPORTED_VIDEO_TYPES[\".mp4\"]]\n+\n+    @pytest.mark.asyncio\n+    async def test_mp4_document_is_treated_as_video(self, adapter):\n+        file_obj = _make_file_obj(b\"fake-mp4-doc\")\n+        doc = _make_document(file_name=\"good.mp4\", mime_type=\"video/mp4\", file_size=1024, file_obj=file_obj)\n+        msg = _make_message(document=doc)\n+        update = _make_update(msg)\n+\n+        await adapter._handle_media_message(update, MagicMock())\n+        event = adapter.handle_message.call_args[0][0]\n+        assert event.message_type == MessageType.VIDEO\n+        assert len(event.media_urls) == 1\n+        assert os.path.exists(event.media_urls[0])\n+        assert event.media_types == [SUPPORTED_VIDEO_TYPES[\".mp4\"]]\n+\n+\n # ---------------------------------------------------------------------------\n # TestMediaGroups — media group (album) buffering\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":13150,"title":"fix(discord): handle inbound video attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4314d451ca961cb50c3430197a3a2c7a8575fd0e","number":0},{"kind":"commit","value":"cc8e5ec2afbfd10a3cff4e710210dd9ecae64a33","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}