Telegram file handling errors and unsupported types
NousResearch/hermes-agent · cluster 23045 · gateway
Data as of
Nothing open on GitHub. Fix merged in #53912. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #53912. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #53912. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address how the Telegram gateway handles unsupported or failing document uploads, including .stl/.3mf files, covering silent failures, empty turns, and routing errors to the correct recipient.
Open issues0
Open PRs0
Fixes on record
-
#53912 fix(telegram): surface failed media downloads to user and agent, not a silent empty turn
files:
plugins/platforms/telegram/adapter.py,tests/gateway/test_telegram_documents.pyShow the diff
-
plugins/platforms/telegram/adapter.py@@ -6101,6 +6101,47 @@ def _append_observed_note(existing: Optional[str], note: str) -> str: return note return f"{existing}\n\n{note}" + async def _surface_media_cache_failure( + self, + msg: Message, + event: MessageEvent, + kind: str, + exc: Exception, + display_name: Optional[str] = None, + ) -> None: + """Surface a failed media download/cache on BOTH ends instead of swallowing it. + + When download_as_bytearray()/cache_*_from_bytes() raises (typically a + transient httpx.ConnectError to Telegram's CDN), the attachment never + made it into event.media_urls. Without this, the handler falls through + and dispatches an empty turn: the user thinks the file was delivered, + the agent sees nothing, and the only record is a buried log warning. + + This (1) replies to the user in Telegram so they know to retry, and + (2) appends an agent-visible notice to event.text via the existing + observed-note channel so the agent knows an attachment was attempted + and failed — never a silent empty turn. No new event fields (the + structured-event refactor is out of scope per #23045). + """ + named = f" ({display_name})" if display_name else "" + try: + await msg.reply_text( + f"\u26a0\ufe0f Couldn't download your {kind}{named} " + f"({exc.__class__.__name__}). Please try sending it again." + ) + except Exception as reply_err: + logger.warning( + "[Telegram] Failed to notify user about %s cache failure: %s", + kind, + reply_err, + exc_info=True, + ) + agent_note = ( + f"[The user attempted to send a {kind}{named} but it could not be " + f"downloaded ({exc.__class__.__name__}); they have been asked to retry.]" + ) + event.text = self._append_observed_note(event.text, agent_note) + def _observe_unmentioned_group_message( self, message: Message, @@ -6562,6 +6603,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA except Exception as e: logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True) + await self._surface_media_cache_failure(msg, event, "photo", e) # Download voice/audio messages to cache for STT transcription if msg.voice: @@ -6580,6 +6622,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA logger.info("[Telegram] Cached user voice at %s", cached_path) except Exception as e: logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True) + await self._surface_media_cache_failure(msg, event, "voice message", e) elif msg.audio: try: allowed, note = self._telegram_media_size_allowed(msg.audio, "audio file") @@ -6596,6 +6639,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA logger.info("[Telegram] Cached user audio at %s", cached_path) except Exception as e: logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True) + await self._surface_media_cache_failure(msg, event, "audio file", e) elif msg.video: try: @@ -6619,6 +6663,7 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA 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) + await self._surface_media_cache_failure(msg, event, "video file", e) # Download document files to cache for agent processing elif msg.document: @@ -6751,6 +6796,10 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA except Exception as e: logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True) + await self._surface_media_cache_failure( + msg, event, "attachment", e, + display_name=getattr(doc, "file_name", None) or None, + ) media_group_id = getattr(msg, "media_group_id", None) if media_group_id: -
tests/gateway/test_telegram_documents.py@@ -106,6 +106,7 @@ def _make_message(document=None, caption=None, media_group_id=None, photo=None): msg.from_user.id = 1 msg.from_user.full_name = "Test User" msg.message_thread_id = None + msg.reply_text = AsyncMock() return msg @@ -397,7 +398,7 @@ async def test_text_injection_capped(self, adapter): @pytest.mark.asyncio async def test_download_exception_handled(self, adapter): - """If get_file() raises, the handler logs the error without crashing.""" + """If get_file() raises, the handler surfaces the failure without crashing.""" doc = _make_document(file_name="crash.pdf", file_size=100) doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram API down")) msg = _make_message(document=doc) @@ -406,8 +407,73 @@ async def test_download_exception_handled(self, adapter): # Should not raise await adapter._handle_media_message(update, MagicMock()) # handle_message should still be called (the handler catches the exception) + # so the agent gets a turn — but now that turn carries a notice instead + # of being an empty/content-less event. adapter.handle_message.assert_called_once() + @pytest.mark.asyncio + async def test_document_cache_failure_replies_and_signals_agent(self, adapter): + """A failed document download must surface on BOTH ends, not silently. + + Regression for #23045 Bug 2: a CDN download/cache failure used to log a + warning and fall through to an empty agent turn — user thinks the file + arrived, agent sees nothing. Now the user gets a Telegram reply AND the + agent's event.text carries an attempted-attachment notice. + """ + doc = _make_document(file_name="notes.md", mime_type="text/markdown", file_size=100) + doc.get_file = AsyncMock(side_effect=RuntimeError("Telegram CDN down")) + msg = _make_message(document=doc) + update = _make_update(msg) + + await adapter._handle_media_message(update, MagicMock()) + + # 1. User is told the download failed, with the filename + exception type. + msg.reply_text.assert_awaited_once() + reply = msg.reply_text.await_args.args[0] + assert "Couldn't download" in reply + assert "notes.md" in reply + assert "RuntimeError" in reply + + # 2. The agent still gets a turn, but event.text now carries a notice so + # it knows an attachment was attempted and failed (not a silent empty turn). + adapter.handle_message.assert_called_once() + event = adapter.handle_message.call_args[0][0] + assert event.media_urls == [] # nothing cached + assert "could not be downloaded" in (event.text or "") + assert "notes.md" in (event.text or "") + + @pytest.mark.asyncio + async def test_document_cache_failure_reply_error_is_nonfatal(self, adapter): + """If even the user-reply fails, the agent notice is still appended.""" + doc = _make_document(file_name="x.bin", mime_type="application/octet-stream", file_size=100) + doc.get_file = AsyncMock(side_effect=RuntimeError("CDN down")) + msg = _make_message(document=doc) + msg.reply_text = AsyncMock(side_effect=RuntimeError("reply failed too")) + update = _make_update(msg) + + # Must not raise despite reply_text blowing up + await adapter._handle_media_message(update, MagicMock()) + adapter.handle_message.assert_called_once() + event = adapter.handle_message.call_args[0][0] + assert "could not be downloaded" in (event.text or "") + + @pytest.mark.asyncio + async def test_voice_cache_failure_replies_and_signals_agent(self, adapter): + """Same fail-closed contract applies to the voice site (#23045 Bug 2 class).""" + msg = _make_message() + msg.voice = MagicMock() + msg.voice.file_size = 100 + msg.voice.get_file = AsyncMock(side_effect=RuntimeError("CDN down")) + update = _make_update(msg) + + await adapter._handle_media_message(update, MagicMock()) + + msg.reply_text.assert_awaited_once() + assert "voice message" in msg.reply_text.await_args.args[0] + adapter.handle_message.assert_called_once() + event = adapter.handle_message.call_args[0][0] + assert "could not be downloaded" in (event.text or "") + class TestVideoDownloadBlock: @pytest.mark.asyncio
-
-
#65510 fix(gateway): deliver MEDIA: tags for every file type via validated egress
files:
gateway/platforms/base.py,tests/gateway/test_platform_base.pyShow the diff
-
gateway/platforms/base.py@@ -1504,12 +1504,16 @@ def _log_safe_path(path: str) -> str: re.IGNORECASE, ) -# Extension-less absolute paths (e.g. Caddyfile, Dockerfile, Makefile) are -# intentionally excluded from MEDIA_TAG_CLEANUP_RE — they are validated and -# delivered via MEDIA_EXTENSIONLESS_TAG_RE so prompt-injection paths that do -# not exist on disk are left visible instead of silently dropped. Paths with -# an unknown but present extension (e.g. .weirdext) stay on the #34517 -# bare-path fallback and are not handled here. +# Paths NOT covered by MEDIA_TAG_CLEANUP_RE's extension alternation — both +# extension-less files (Caddyfile, Dockerfile, Makefile) and files with an +# unknown extension (.py, .log, .weirdext, ...) — are validated and delivered +# via MEDIA_EXTENSIONLESS_TAG_RE. Every ``MEDIA:`` path is therefore +# deliverable regardless of file type (#36060): known extensions extract +# unconditionally via the anchored pattern above, everything else extracts +# only after ``validate_media_delivery_path`` accepts it (exists on disk, not +# under the credential/system denylist, strict-mode rules honored), so +# prompt-injection paths that do not validate are left visible instead of +# silently dropped. MEDIA_EXTENSIONLESS_TAG_RE = re.compile( r'''[`"']?MEDIA:\s*''' r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|''' @@ -1527,8 +1531,16 @@ def _normalize_media_tag_path(raw: str) -> str: def _path_lacks_deliverable_extension(path: str) -> bool: - """True only when the basename has no extension (Caddyfile, Makefile, …).""" - return not Path(path).suffix + """True when MEDIA_TAG_CLEANUP_RE's extension alternation does not cover + ``path`` — either the basename has no extension at all (Caddyfile, + Makefile, …) or the extension is not in MEDIA_DELIVERY_EXTS (.py, .log, + .weirdext, …). Such paths route through the validated delivery pass + (``validate_media_delivery_path``) instead of the unconditional one, so + every file type is deliverable (#36060) while nonexistent / denylisted + paths stay visible in the text. + """ + suffix = Path(path).suffix.lower() + return not suffix or suffix not in MEDIA_DELIVERY_EXTS def _strip_media_tag_directives(text: str) -> str: -
tests/gateway/test_platform_base.py@@ -631,12 +631,15 @@ def test_extract_media_and_local_files_share_one_extension_set(self): def test_unknown_extension_not_black_holed_by_cleanup(self): """A MEDIA: tag with an unknown extension is NOT stripped from the - body — it survives so extract_local_files can still see the bare path, - rather than vanishing entirely (the core of issue #34517).""" + body by the extension-anchored cleanup — and when the path does not + validate (nonexistent file here), it is not delivered either, so the + tag survives visibly instead of vanishing (the core of issue #34517). + Validated unknown-extension paths DO deliver — see + TestUniversalMediaEgress (#36060).""" from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE text = "Saved to MEDIA:/tmp/data.weirdext done" media, _ = BasePlatformAdapter.extract_media(text) - assert media == [] # unknown extension is not a deliverable MEDIA tag + assert media == [] # nonexistent path fails validation, not delivered stripped = MEDIA_TAG_CLEANUP_RE.sub("", text) assert "/tmp/data.weirdext" in stripped # path preserved, not dropped @@ -712,6 +715,104 @@ def test_as_document_directive_stripped_without_media_tag(self): ) +class TestUniversalMediaEgress: + """#36060: every MEDIA: path is deliverable regardless of file type. + + Known extensions extract unconditionally (MEDIA_TAG_CLEANUP_RE); unknown + extensions and extension-less files extract via the validated pass — + delivered when validate_media_delivery_path accepts them, left visible + when it does not (nonexistent, denylisted). + """ + + def _patch_allow_root(self, monkeypatch, root): + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (str(root),), + ) + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + + @pytest.mark.parametrize("name", [ + "script.py", "server.log", "notes.weirdext", "app.ts", "run.sh", + "config.toml", "styles.css", "contract.sol", + ]) + def test_unknown_extension_delivered_when_file_validates( + self, tmp_path, monkeypatch, name, + ): + root = tmp_path / "output" + root.mkdir() + f = root / name + f.write_text("content", encoding="utf-8") + self._patch_allow_root(monkeypatch, root) + + content = f"Here you go:\nMEDIA:{f}\nDone." + media, cleaned = BasePlatformAdapter.extract_media(content) + assert len(media) == 1 + assert media[0][0] == str(f.resolve()) + assert "MEDIA:" not in cleaned + assert "Done." in cleaned + + def test_unknown_extension_left_visible_when_not_on_disk( + self, tmp_path, monkeypatch, + ): + root = tmp_path / "output" + root.mkdir() + self._patch_allow_root(monkeypatch, root) + + content = "MEDIA:/nonexistent/script.py" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [] + assert "MEDIA:/nonexistent/script.py" in cleaned + + def test_denylisted_paths_still_rejected_regardless_of_extension( + self, tmp_path, monkeypatch, + ): + # A denylisted path must not deliver even though .py/.log/etc now + # route through the validated pass. _media_delivery_denied_paths() + # reads _MEDIA_DELIVERY_DENIED_PREFIXES at call time, so patching the + # tuple exercises the real denylist logic. + secret_dir = [redacted:secret-assignment] / "secrets" + secret_dir.mkdir() + f = secret_dir / "creds.py" + f.write_text("TOKEN = 'x'", encoding="utf-8") + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(secret_dir),), + ) + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + + content = f"MEDIA:{f}" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [] + assert "MEDIA:" in cleaned # rejected tag stays visible + + def test_strip_for_display_strips_validated_unknown_extension( + self, tmp_path, monkeypatch, + ): + root = tmp_path / "output" + root.mkdir() + f = root / "server.log" + f.write_text("x", encoding="utf-8") + self._patch_allow_root(monkeypatch, root) + + text = f"MEDIA:{f}" + stripped = BasePlatformAdapter.strip_media_directives_for_display(text) + assert "MEDIA:" not in stripped + + def test_strip_for_display_keeps_unvalidated_unknown_extension(self): + text = "MEDIA:/nonexistent/server.log" + stripped = BasePlatformAdapter.strip_media_directives_for_display(text) + assert "MEDIA:/nonexistent/server.log" in stripped + + def test_known_extension_still_unconditional(self): + # Known extensions keep the pre-#36060 behavior: extracted (and the + # tag stripped) even when the file does not exist — downstream + # delivery surfaces the failure. + content = "MEDIA:/nonexistent/report.pdf" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [("/nonexistent/report.pdf", False)] + assert "MEDIA:" not in cleaned + + class TestMediaDeliveryPathValidation: def _patch_roots(self, monkeypatch, *roots): monkeypatch.setattr(
-
6 PRs closed without merging
From the close comments: done elsewhere 4 · duplicate 2
-
#23063 fix: keep telegram document failures out of agent input · done elsewhere →
db16854,#53912,#23045 -
#23241 fix(telegram): route unsupported docs and cache errors to user, not agent · done elsewhere →
db16854,#53912,#23045 -
#23349 fix(telegram): reply to user on unsupported docs and cache failures · duplicate →
#23061,#23045,#23063,#23241 -
#46680 fix: avoid empty turns when Telegram documents fail to download · duplicate →
#23063,#23045,#23061,#41087 -
#53354 fix(gateway): accept .stl and .3mf document uploads · done elsewhere →
4314d45
Show 1 more
-
#60069 fix(gateway): allow .py/.markdown/.stl/.3mf media & document attachments · done elsewhere →
#65510
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.19: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab