{"repo":"NousResearch/hermes-agent","public_id":"24870","display_label":"Telegram audio files misrouted to STT pipeline","summary":"These issues and PRs address Telegram audio file attachments being incorrectly classified as voice messages and sent through the speech-to-text pipeline. Work includes fixes to routing logic and a feature to skip STT for audio files, with a 2GB cap via local Bot API server.","subsystem":null,"counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28478},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-19T05:59:41+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.25},"fix_edges":[{"issue_number":24870,"pr_number":28478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":24870,"pr_number":28541,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":26207,"pr_number":28478,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":24870,"title":"Telegram: audio file attachments misclassified as voice messages, routed to STT pipeline","url":null,"state":"closed"},{"kind":"pr","number":24879,"title":"fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28478","number":28478}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":24883,"title":"fix(telegram): keep audio attachments as files","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24879","number":24879},{"kind":"pr","value":"#24870","number":24870},{"kind":"pr","value":"#24879","number":24879}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":25097,"title":"fix(gateway): route audio file attachments as files, not STT input","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#24879","number":24879},{"kind":"pr","value":"#24883","number":24883},{"kind":"pr","value":"#24870","number":24870}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":25274,"title":"feat(telegram): skip-STT audio path + 2GB cap via local Bot API server","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":25280,"title":"feat(telegram): skip-STT audio path + 2GB cap via local Bot API server","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28541","number":28541}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28478,"title":"fix(gateway): route Telegram audio file attachments away from STT pipeline (#24870)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","scripts/release.py","tests/gateway/test_telegram_audio_vs_voice.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -7257,11 +7257,19 @@ async def _prepare_inbound_message_text(\n         if event.media_urls:\n             image_paths = []\n             audio_paths = []\n+            audio_file_paths: list[str] = []  # audio file attachments — not for STT\n             for i, path in enumerate(event.media_urls):\n                 mtype = event.media_types[i] if i < len(event.media_types) else \"\"\n                 if mtype.startswith(\"image/\") or event.message_type == MessageType.PHOTO:\n                     image_paths.append(path)\n-                if mtype.startswith(\"audio/\") or event.message_type in {MessageType.VOICE, MessageType.AUDIO}:\n+                # MessageType.AUDIO = audio file attachment (e.g. .mp3, .m4a) — never STT\n+                # MessageType.VOICE = voice message (Opus/OGG) — always STT\n+                if event.message_type == MessageType.AUDIO:\n+                    audio_file_paths.append(path)\n+                elif event.message_type == MessageType.VOICE or (\n+                    mtype.startswith(\"audio/\")\n+                    and event.message_type not in {MessageType.AUDIO, MessageType.DOCUMENT}\n+                ):\n                     audio_paths.append(path)\n \n             if image_paths:","@@ -7323,6 +7331,21 @@ async def _prepare_inbound_message_text(\n                         except Exception:\n                             pass\n \n+        if audio_file_paths:\n+            from tools.credential_files import to_agent_visible_cache_path as _to_agent_path\n+            for _apath in audio_file_paths:\n+                _basename = os.path.basename(_apath)\n+                _parts = _basename.split(\"_\", 2)\n+                _display = _parts[2] if len(_parts) >= 3 else _basename\n+                _display = re.sub(r'[^\\w.\\- ]', '_', _display)\n+                _agent_path = _to_agent_path(_apath)\n+                _note = (\n+                    f\"[The user sent an audio file attachment: '{_display}'. \"\n+                    f\"It is saved at: {_agent_path}. \"\n+                    f\"Ask the user what they'd like you to do with it, or pass the path to a transcription or media tool.]\"\n+                )\n+                message_text = f\"{_note}\\n\\n{message_text}\"\n+\n         if event.media_urls and event.message_type == MessageType.DOCUMENT:\n             import mimetypes as _mimetypes\n             from tools.credential_files import to_agent_visible_cache_path"]},{"file":"scripts/release.py","hunks":["@@ -165,6 +165,7 @@\n     \"[redacted:email]\": \"dengtaoyuan450-a11y\",\n     \"[redacted:email]\": \"Junass1\",\n     \"[redacted:email]\": \"Bartok9\",\n+    \"[redacted:email]\": \"Bartok9\",\n     \"[redacted:email]\": \"hllqkb\",\n     \"[redacted:email]\": \"Bongulielmi\",\n     \"[redacted:email]\": \"JTroyerOvermatch\","]},{"file":"tests/gateway/test_telegram_audio_vs_voice.py","hunks":["@@ -0,0 +1,184 @@\n+\"\"\"\n+Tests for #24870 — Telegram: audio file attachments must NOT be routed to STT.\n+\n+Telegram distinguishes three kinds of audio payloads:\n+  - message.voice  → Opus/OGG voice message  → STT pipeline\n+  - message.audio  → audio file attachment   → file path note, NOT STT\n+  - message.document (audio mime) → generic file route\n+\n+These tests confirm that:\n+  1. MessageType.VOICE events still flow through the STT pipeline.\n+  2. MessageType.AUDIO events bypass STT and get a file-path context note instead.\n+  3. Mixed media lists (voice + audio) split correctly.\n+\"\"\"\n+\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.session import SessionSource\n+\n+\n+def _make_runner(stt_enabled: bool = True) -> \"GatewayRunner\":  # type: ignore[name-defined]\n+    from gateway.run import GatewayRunner\n+\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(stt_enabled=stt_enabled)\n+    runner.adapters = {}\n+    runner._model = \"test-model\"\n+    runner._base_url = \"\"\n+    runner._has_setup_skill = lambda: False\n+    return runner\n+\n+\n+def _voice_event(path: str = \"/tmp/voice.ogg\") -> MessageEvent:\n+    return MessageEvent(\n+        text=\"\",\n+        message_type=MessageType.VOICE,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\"),\n+        media_urls=[path],\n+        media_types=[\"audio/ogg\"],\n+    )\n+\n+\n+def _audio_event(path: str = \"/tmp/song.mp3\") -> MessageEvent:\n+    return MessageEvent(\n+        text=\"\",\n+        message_type=MessageType.AUDIO,\n+        source=SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\"),\n+        media_urls=[path],\n+        media_types=[\"audio/mpeg\"],\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1. VOICE still goes through STT\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_voice_message_still_transcribed():\n+    \"\"\"MessageType.VOICE must still be sent through _enrich_message_with_transcription.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _voice_event(\"/tmp/voice.ogg\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        return_value={\"success\": True, \"transcript\": \"hello world\", \"provider\": \"whisper\"},\n+    ) as mock_transcribe:\n+        result = await runner._prepare_inbound_message_text(\n+            event=event,\n+            source=source,\n+            history=[],\n+        )\n+\n+    mock_transcribe.assert_called_once_with(\"/tmp/voice.ogg\")\n+    assert \"hello world\" in result\n+    assert \"voice message\" in result.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# 2. AUDIO file attachment bypasses STT\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_audio_attachment_skips_stt():\n+    \"\"\"MessageType.AUDIO must NOT be routed to STT — transcribe_audio must not be called.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/song.mp3\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"transcribe_audio must NOT be called for audio file attachments\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    assert result is not None\n+    assert \"/tmp/song.mp3\" in result\n+    assert \"audio file attachment\" in result.lower()\n+\n+\n[redacted:email]\n+async def test_audio_attachment_context_note_format():\n+    \"\"\"Context note for audio file attachments should include the file path and guidance.\"\"\"\n+    runner = _make_runner(stt_enabled=True)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/cache_12345_my_song.mp3\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"must not be called\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    assert \"my_song.mp3\" in result\n+    assert \"audio file attachment\" in result.lower()\n+    # Should NOT contain the voice-message transcription wrapper text\n+    assert \"voice message\" not in result.lower()\n+\n+\n+# ---------------------------------------------------------------------------\n+# 3. STT disabled still results in no transcription for audio file attachments\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+async def test_audio_attachment_skips_stt_when_stt_disabled():\n+    \"\"\"Even with STT disabled, AUDIO must NOT produce STT disabled notice — just a file note.\"\"\"\n+    runner = _make_runner(stt_enabled=False)\n+    source = SessionSource(platform=Platform.TELEGRAM, chat_id=\"1\", chat_type=\"dm\")\n+    event = _audio_event(\"/tmp/podcast.m4a\")\n+\n+    with patch(\n+        \"tools.transcription_tools.transcribe_audio\",\n+        side_effect=AssertionError(\"must not be called\"),\n+    ):\n+        with patch(\n+            \"tools.credential_files.to_agent_visible_cache_path\",\n+            side_effect=lambda p: p,\n+        ):\n+            result = await runner._prepare_inbound_message_text(\n+                event=event,\n+                source=source,\n+                history=[],\n+            )\n+\n+    # Should NOT see the \"transcription is disabled\" note — that's only for VOICE\n+    assert \"transcription is disabled\" not in result.lower()\n+    assert \"audio file attachment\" in result.lower()\n+    assert \"/tmp/podcast.m4a\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# 4. Telegram gateway: msg.audio → MessageType.AUDIO (not VOICE)\n+# ---------------------------------------------------------------------------\n+\n+def test_telegram_media_type_detection_audio_vs_voice():\n+    \"\"\"The Telegram platform must set MessageType.AUDIO for msg.audio, VOICE for msg.voice.\"\"\"\n+    from gateway.platforms.base import MessageType\n+\n+    # The Telegram adapter's _build_media_type already returns correct values\n+    # via MessageType.AUDIO for .audio and MessageType.VOICE for .voice.\n+    # Check the constants match expected semantic roles.\n+    assert MessageType.AUDIO.value == \"audio\"\n+    assert MessageType.VOICE.value == \"voice\"\n+    # Sanity: they are distinct\n+    assert MessageType.AUDIO != MessageType.VOICE\n"]}]},{"kind":"pr","number":28541,"title":"feat(telegram): skip-STT audio path + 2GB cap via local Bot API server","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","gateway/run.py","scripts/release.py","tests/gateway/test_stt_config.py","tests/gateway/test_telegram_max_doc_bytes.py","website/docs/user-guide/features/voice-mode.md","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -439,6 +439,14 @@ def __init__(self, config: PlatformConfig):\n         self._dm_topic_chat_ids: Set[str] = {\n             str(e[\"chat_id\"]) for e in self._dm_topics_config if \"chat_id\" in e\n         }\n+        # Document size cap. Telegram's public Bot API caps getFile at 20MB; a\n+        # locally-hosted telegram-bot-api server (configured via extra.base_url)\n+        # raises that to 2GB, so the presence of base_url is the opt-in.\n+        self._max_doc_bytes: int = (\n+            2 * 1024 * 1024 * 1024\n+            if self.config.extra.get(\"base_url\")\n+            else 20 * 1024 * 1024\n+        )\n         # Interactive model picker state per chat\n         self._model_picker_state: Dict[str, dict] = {}\n         # Approval button state: message_id → session_key","@@ -1315,6 +1323,14 @@ async def connect(self) -> bool:\n                     \"[%s] Using custom Telegram base_url: %s\",\n                     self.name, custom_base_url,\n                 )\n+            # In local-mode telegram-bot-api, file_path is an absolute path on the\n+            # server's filesystem rather than a relative HTTP path. PTB needs\n+            # local_mode=True so download_*() reads from disk instead of issuing\n+            # an HTTP GET that would 404. Requires that the same path is\n+            # readable by the Hermes process (shared mount, same machine, etc.).\n+            if self.config.extra.get(\"local_mode\"):\n+                builder = builder.local_mode(True)\n+                logger.info(\"[%s] Using Telegram local_mode (read files from disk)\", self.name)\n \n             # PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and\n             # can trigger \"Pool timeout: All connections in the connection pool are occupied\"","@@ -4894,11 +4910,11 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA\n \n                 # Check file size early so image documents cannot bypass the\n                 # document size limit by taking the image path.\n-                MAX_DOC_BYTES = 20 * 1024 * 1024\n-                if not doc.file_size or doc.file_size > MAX_DOC_BYTES:\n+                if not doc.file_size or doc.file_size > self._max_doc_bytes:\n+                    limit_mb = self._max_doc_bytes // (1024 * 1024)\n                     event.text = (\n                         \"The document is too large or its size could not be verified. \"\n-                        \"Maximum: 20 MB.\"\n+                        f\"Maximum: {limit_mb} MB.\"\n                     )\n                     logger.info(\"[Telegram] Document too large: %s bytes\", doc.file_size)\n                     await self.handle_message(event)"]},{"file":"gateway/run.py","hunks":["@@ -918,6 +918,59 @@ def _build_media_placeholder(event) -> str:\n     return \"\\n\".join(parts)\n \n \n+def _format_duration(seconds: float) -> str:\n+    total = int(round(seconds))\n+    if total < 0:\n+        total = 0\n+    hours, rem = divmod(total, 3600)\n+    minutes, secs = divmod(rem, 60)\n+    if hours:\n+        return f\"{hours}:{minutes:02d}:{secs:02d}\"\n+    return f\"{minutes}:{secs:02d}\"\n+\n+\n+async def _probe_audio_duration(path: str) -> Optional[str]:\n+    \"\"\"Best-effort duration probe. Returns formatted MM:SS / HH:MM:SS, or None on failure.\"\"\"\n+    ext = os.path.splitext(path)[1].lower()\n+\n+    if ext == \".wav\":\n+        try:\n+            def _wav_duration() -> float:\n+                import wave\n+                with wave.open(path, \"rb\") as wf:\n+                    frames = wf.getnframes()\n+                    rate = wf.getframerate() or 1\n+                    return frames / float(rate)\n+            secs = await asyncio.to_thread(_wav_duration)\n+            return _format_duration(secs)\n+        except Exception:\n+            pass\n+\n+    if ext in (\".ogg\", \".opus\", \".oga\"):\n+        try:\n+            def _ogg_duration() -> float:\n+                from mutagen.oggopus import OggOpus\n+                return float(OggOpus(path).info.length)\n+            secs = await asyncio.to_thread(_ogg_duration)\n+            return _format_duration(secs)\n+        except Exception:\n+            pass\n+\n+    try:\n+        proc = await asyncio.create_subprocess_exec(\n+            \"ffprobe\", \"-v\", \"error\", \"-show_entries\", \"format=duration\",\n+            \"-of\", \"default=noprint_wrappers=1:nokey=1\", path,\n+            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,\n+        )\n+        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)\n+        if proc.returncode == 0:\n+            return _format_duration(float(stdout.decode().strip()))\n+    except Exception:\n+        pass\n+\n+    return None\n+\n+\n def _dequeue_pending_event(adapter, session_key: str) -> MessageEvent | None:\n     \"\"\"Consume and return the full pending event for a session.\n ","@@ -14151,16 +14204,25 @@ async def _enrich_message_with_transcription(\n             The enriched message string with transcriptions prepended.\n         \"\"\"\n         if not getattr(self.config, \"stt_enabled\", True):\n-            disabled_note = \"[The user sent voice message(s), but transcription is disabled in config.\"\n-            if self._has_setup_skill():\n-                disabled_note += (\n-                    \" You have a skill called hermes-agent-setup that can help \"\n-                    \"users configure Hermes features including voice, tools, and more.\"\n-                )\n-            disabled_note += \"]\"\n+            notes = []\n+            for path in audio_paths:\n+                abs_path = os.path.abspath(path)\n+                duration_str = await _probe_audio_duration(abs_path)\n+                if duration_str:\n+                    notes.append(\n+                        f\"[The user sent a voice message: {abs_path} (duration: {duration_str})]\"\n+                    )\n+                else:\n+                    notes.append(f\"[The user sent a voice message: {abs_path}]\")\n+            if not notes:\n+                return user_text\n+            prefix = \"\\n\\n\".join(notes)\n+            _placeholder = \"(The user sent a message with no text content)\"\n+            if user_text and user_text.strip() == _placeholder:\n+                return prefix\n             if user_text:\n-                return f\"{disabled_note}\\n\\n{user_text}\"\n-            return disabled_note\n+                return f\"{prefix}\\n\\n{user_text}\"\n+            return prefix\n \n         from tools.transcription_tools import transcribe_audio\n "]},{"file":"scripts/release.py","hunks":["@@ -192,6 +192,7 @@\n     \"[redacted:email]\": \"OCWC22\",  # PR #24581 salvage (multi-bot exclusive mentions)\n     \"[redacted:email]\": \"ai-hana-ai\",  # PR #23928 salvage (ignore_root_dm)\n     \"[redacted:email]\": \"indigokarasu\",  # PR #26636 salvage (pin user message)\n+    \"[redacted:email]\": \"alber70g\",  # PR #25280 salvage (skip-STT + 2GB cap)\n     \"[redacted:email]\": \"eliteworkstation94-ai\",  # PR #28157 salvage (group reply session splits)\n     \"[redacted:email]\": \"hllqkb\",\n     \"[redacted:email]\": \"Bongulielmi\","]},{"file":"tests/gateway/test_stt_config.py","hunks":["@@ -33,25 +33,51 @@ def test_load_gateway_config_bridges_stt_enabled_from_config_yaml(tmp_path, monk\n \n \n @pytest.mark.asyncio\n-async def test_enrich_message_with_transcription_skips_when_stt_disabled():\n+async def test_enrich_message_with_transcription_surfaces_path_when_stt_disabled():\n     from gateway.run import GatewayRunner\n \n     runner = GatewayRunner.__new__(GatewayRunner)\n     runner.config = GatewayConfig(stt_enabled=False)\n+    runner._has_setup_skill = lambda: True  # Should NOT be consulted in disabled branch.\n \n     with patch(\n         \"tools.transcription_tools.transcribe_audio\",\n         side_effect=AssertionError(\"transcribe_audio should not be called when STT is disabled\"),\n+    ), patch(\n+        \"gateway.run._probe_audio_duration\",\n+        new=AsyncMock(return_value=\"0:12\"),\n     ):\n         result = await runner._enrich_message_with_transcription(\n             \"caption\",\n             [\"/tmp/voice.ogg\"],\n         )\n \n-    assert \"transcription is disabled\" in result.lower()\n+    assert \"/tmp/voice.ogg\" in result\n+    assert \"voice message\" in result.lower()\n+    assert \"(duration: 0:12)\" in result\n     assert \"caption\" in result\n \n \n[redacted:email]\n+async def test_enrich_message_with_transcription_omits_duration_on_probe_failure():\n+    from gateway.run import GatewayRunner\n+\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(stt_enabled=False)\n+\n+    with patch(\n+        \"gateway.run._probe_audio_duration\",\n+        new=AsyncMock(return_value=None),\n+    ):\n+        result = await runner._enrich_message_with_transcription(\n+            \"\",\n+            [\"/tmp/voice.ogg\"],\n+        )\n+\n+    assert \"/tmp/voice.ogg\" in result\n+    assert \"duration\" not in result.lower()\n+\n+\n @pytest.mark.asyncio\n async def test_enrich_message_with_transcription_avoids_bogus_no_provider_message_for_backend_key_errors():\n     from gateway.run import GatewayRunner"]},{"file":"tests/gateway/test_telegram_max_doc_bytes.py","hunks":["@@ -0,0 +1,56 @@\n+\"\"\"Tests for Telegram document-size cap.\n+\n+The public Telegram Bot API caps `getFile` at 20MB. A locally-hosted\n+`telegram-bot-api` server raises that ceiling to 2GB. We treat the presence\n+of `extra.base_url` as the explicit opt-in to the higher cap.\n+\"\"\"\n+\n+import sys\n+from unittest.mock import MagicMock\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_telegram_mock():\n+    if \"telegram\" in sys.modules and hasattr(sys.modules[\"telegram\"], \"__file__\"):\n+        return\n+\n+    telegram_mod = MagicMock()\n+    telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)\n+    telegram_mod.constants.ParseMode.MARKDOWN_V2 = \"MarkdownV2\"\n+    telegram_mod.constants.ChatType.GROUP = \"group\"\n+    telegram_mod.constants.ChatType.SUPERGROUP = \"supergroup\"\n+    telegram_mod.constants.ChatType.CHANNEL = \"channel\"\n+    telegram_mod.constants.ChatType.PRIVATE = \"private\"\n+\n+    for name in (\"telegram\", \"telegram.ext\", \"telegram.constants\", \"telegram.request\"):\n+        sys.modules.setdefault(name, telegram_mod)\n+\n+\n+_ensure_telegram_mock()\n+\n+from gateway.platforms.telegram import TelegramAdapter  # noqa: E402\n+\n+\n+def test_max_doc_bytes_defaults_to_20mb_without_base_url():\n+    adapter = TelegramAdapter(PlatformConfig(enabled=True, token=\"***\", extra={}))\n+    assert adapter._max_doc_bytes == 20 * 1024 * 1024\n+\n+\n+def test_max_doc_bytes_raised_to_2gb_when_base_url_set():\n+    adapter = TelegramAdapter(\n+        PlatformConfig(\n+            enabled=True,\n+            token=\"***\",\n+            extra={\"base_url\": \"http://localhost:8081/bot\"},\n+        )\n+    )\n+    assert adapter._max_doc_bytes == 2 * 1024 * 1024 * 1024\n+\n+\n+def test_max_doc_bytes_empty_base_url_keeps_default():\n+    \"\"\"An empty/falsy `base_url` should not flip the cap — only a real URL does.\"\"\"\n+    adapter = TelegramAdapter(\n+        PlatformConfig(enabled=True, token=\"***\", extra={\"base_url\": \"\"}),\n+    )\n+    assert adapter._max_doc_bytes == 20 * 1024 * 1024"]},{"file":"website/docs/user-guide/features/voice-mode.md","hunks":["@@ -391,6 +391,11 @@ voice:\n \n # Speech-to-Text\n stt:\n+  enabled: true                     # set to false to skip auto-transcription —\n+                                    # the gateway still caches the audio file and\n+                                    # passes its path to the agent as part of the\n+                                    # inbound message, useful for custom pipelines\n+                                    # (diarization, alignment, archival, etc.)\n   provider: \"local\"                  # \"local\" (free) | \"groq\" | \"openai\"\n   local:\n     model: \"base\"                    # tiny, base, small, medium, large-v3"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -276,6 +276,25 @@ Voice messages you send on Telegram are automatically transcribed by Hermes's co\n - `groq` uses Groq Whisper and requires `GROQ_API_KEY`\n - `openai` uses OpenAI Whisper and requires `VOICE_TOOLS_OPENAI_KEY`\n \n+#### Skipping STT: pass the raw audio file to the agent\n+\n+If you'd rather have the **agent itself** handle audio — for diarization, a custom transcription tool, or just archiving the recording — set `stt.enabled: false` in `~/.hermes/config.yaml`:\n+\n+```yaml\n+stt:\n+  enabled: false\n+```\n+\n+With STT disabled, the gateway still downloads the voice/audio attachment into Hermes's audio cache, but **does not transcribe it**. The agent receives the message with a marker like:\n+\n+```\n+[The user sent a voice message: /home/<user>/.hermes/cache/audio/<hash>.ogg]\n+```\n+\n+Your tools or skills can then read that path directly (e.g., hand it off to a local diarization pipeline, a richer transcription model, or upload it to long-term storage). The file extension reflects the original format Telegram delivered (`.ogg` for voice notes, `.mp3`/`.m4a`/etc. for audio attachments).\n+\n+This pairs naturally with the [local Bot API server](#large-files-20mb--via-local-bot-api-server) section below, which lifts Telegram's 20MB getFile ceiling to 2GB — useful when the recordings you want to process are longer than a couple of minutes.\n+\n ### Outgoing Voice (Text-to-Speech)\n \n When the agent generates audio via TTS, it's delivered as native Telegram **voice bubbles** — the round, inline-playable kind.","@@ -295,6 +314,135 @@ Without ffmpeg, Edge TTS audio is sent as a regular audio file (still playable,\n \n Configure the TTS provider in your `config.yaml` under the `tts.provider` key.\n \n+## Large Files (>20MB) via Local Bot API Server\n+\n+Telegram's **public** Bot API caps `getFile` downloads at **20 MB**, so any voice note, audio file, video, or document larger than that is silently rejected by Hermes with a \"too large\" reply. The documented way around this is to run a **local** [telegram-bot-api](https://github.com/tdlib/telegram-bot-api) daemon — the same server software Telegram uses, but running on your network. A local server raises the file ceiling to **2 GB** and Hermes auto-lifts its own internal cap when it sees a custom `base_url` configured.\n+\n+This unlocks workflows like:\n+\n+- Sending long voice memos (45-minute meetings, podcasts) to the bot\n+- Uploading large videos for vision-tool processing\n+- Archiving raw audio for offline pipelines like diarization, alignment, or training data\n+\n+### Step 1: Obtain Telegram API credentials\n+\n+The local server talks directly to Telegram's MTProto layer (not the public Bot API), so it needs **MTProto credentials**:\n+\n+1. Visit [my.telegram.org/apps](https://my.telegram.org/apps) and sign in with your Telegram account.\n+2. Create a new application (any name and short description will do).\n+3. Copy the `api_id` and `api_hash` — both are required.\n+\n+### Step 2: Run the telegram-bot-api server\n+\n+The community-maintained [`aiogram/telegram-bot-api`](https://hub.docker.com/r/aiogram/telegram-bot-api) Docker image is the easiest path. A minimal `docker-compose.yaml` (use `--local` mode to enable the higher limits):\n+\n+```yaml\n+services:\n+  tg-bot-api:\n+    image: aiogram/telegram-bot-api:latest\n+    container_name: tg-bot-api\n+    restart: unless-stopped\n+    ports:\n+      - \"127.0.0.1:8081:8081\"   # bind to loopback only; see security note\n+    environment:\n+      TELEGRAM_API_ID: \"12345\"           # your api_id from Step 1\n+      TELEGRAM_API_HASH: \"abcdef...\"     # your api_hash from Step 1\n+      TELEGRAM_LOCAL: \"1\"                # enable --local mode (raises 20MB → 2GB)\n+    volumes:\n+      - ./tg-bot-api-data:/var/lib/telegram-bot-api\n+```\n+\n+Bring it up:\n+\n+```bash\n+docker compose up -d tg-bot-api\n+docker logs --tail 20 tg-bot-api\n+```\n+\n+:::warning Security\n+The local Bot API server takes your bot token in the URL path (e.g. `/bot<TOKEN>/getMe`) with **no additional auth**. Anyone who can reach the port can fully control your bot — read every message it can see, send messages as it, etc. Bind the container to `127.0.0.1` and/or front it with a reverse proxy on a private network. **Never expose port 8081 to the public internet.**\n+:::\n+\n+### Step 3: Log the bot out of the public API (one-time)\n+\n+A bot can only be active on **one** Bot API server at a time. If your bot was already running against `api.telegram.org` (which it almost certainly was), you must explicitly log it out there before the local server will accept it:\n+\n+```bash\n+curl \"https://api.telegram.org/bot<YOUR_BOT_TOKEN>/logOut\"\n+# expected response: {\"ok\":true,\"result\":true}\n+```\n+\n+This is a one-shot migration step — you don't repeat it on every restart. Telegram delivers any messages received after `logOut` through the new server instead.\n+\n+Verify the local server can talk to Telegram on the bot's behalf:\n+\n+```bash\n+curl \"http://127.0.0.1:8081/bot<YOUR_BOT_TOKEN>/getMe\"\n+# expected response: {\"ok\":true,\"result\":{\"id\":...,\"is_bot\":true,...}}\n+```\n+\n+### Step 4: Point Hermes at the local server\n+\n+Add the URLs under `platforms.telegram.extra` in `~/.hermes/config.yaml`:\n+\n+```yaml\n+platforms:\n+  telegram:\n+    extra:\n+      base_url: \"http://127.0.0.1:8081/bot\"\n+      base_file_url: \"http://127.0.0.1:8081/file/bot\"\n+      local_mode: true        # see Step 5 below — only set this if the bot's data\n+                              # directory is readable by the Hermes process\n+```\n+\n+:::caution Use `platforms.telegram.extra`, not `telegram.extra`\n+At the moment only the `platforms.<name>.extra` form is deep-merged into the platform config. Keys placed directly under a top-level `telegram.extra` block are silently dropped.\n+:::\n+\n+When `base_url` is set, Hermes:\n+\n+- Builds the python-telegram-bot client against the local server\n+- Auto-lifts its internal document/audio size cap from 20 MB → 2 GB\n+- Reports the active limit in the \"too large\" error message (`Maximum: 2048 MB.`) so it's obvious which mode you're in\n+\n+Restart the gateway and look for a confirmation log line:\n+\n+```bash\n+hermes gateway restart\n+grep -E \"Using custom Telegram base_url|Using Telegram local_mode\" ~/.hermes/logs/gateway.log | tail\n+```\n+\n+### Step 5: `local_mode` — file access on disk\n+\n+The local server has **two ways** to deliver files:\n+\n+1. **Without `--local`** (the default): files are served over HTTP at `/file/bot<TOKEN>/<path>`, same as the public Bot API. The 20MB ceiling stays in effect. Useful as a network-fix only (e.g. when `api.telegram.org` is unreachable but you can self-host); not what you want for the size lift.\n+2. **With `--local`** (set via `TELEGRAM_LOCAL=1` above): files are written to the server's filesystem and the `getFile` response returns an **absolute path** instead of an HTTP URL. The 20MB ceiling is lifted. Hermes must then read the bytes **from disk**, not over HTTP.\n+\n+To make the disk-read path work, set `local_mode: true` in the config above **and** make sure the Hermes process can read the path the server returns. Two scenarios:\n+\n+- **Same machine** — telegram-bot-api and Hermes run on the same host. Bind-mount the data volume to a directory that Hermes can read (e.g., `/var/lib/telegram-bot-api`), and make sure the file ownership matches. The container drops privileges to its internal `telegram-bot-api` user (uid varies by image); the simplest fix is to add `user: \"<UID>:<GID>\"` to the compose service so files are owned by a uid Hermes already runs as.\n+- **Different machines** — the bot server runs on one host (e.g., a NAS, a separate VM) and Hermes on another. The server's data directory must be shared with the Hermes machine at the **same absolute path** the server reports (typically `/var/lib/telegram-bot-api`). NFS works well for this; CIFS/SMB with `uid=` mount remapping is friendlier if you don't want to deal with uid mismatches at the filesystem level.\n+\n+If `local_mode: true` is set but Hermes can't `stat` the returned file path (permissions or wrong mount), python-telegram-bot silently falls back to an HTTP `getFile` against the local server — which in `--local` mode responds with `404 Not Found`. The symptom shows up in `gateway.log` as:\n+\n+```\n+[Telegram] Failed to cache voice: Not Found\n+telegram.error.InvalidToken: Not Found\n+```\n+\n+If you see that, the cap-lift is working but the file-share isn't. Verify `ls -la /var/lib/telegram-bot-api/<TOKEN>/voice/` from the Hermes host as the user the gateway runs as, and confirm a single file is `cat`-able without a permission error.\n+\n+### Step 6: Test it\n+\n+Send the bot a voice note or audio file that's bigger than 20 MB. Tail the gateway log:\n+\n+```bash\n+tail -f ~/.hermes/logs/gateway.log | grep -iE \"telegram|cache\"\n+```\n+\n+You should see a `[Telegram] Cached user voice at /home/<user>/.hermes/cache/audio/...` line and **no** \"too large\" rejection. Combined with `stt.enabled: false` (above), the path to the original audio file then lands in the agent's inbound message for downstream processing.\n+\n ## Group Chat Usage\n \n Hermes Agent works in Telegram group chats with a few considerations:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}