From d28a8cf11c536416ad8c27f08031fe6c1d588053 Mon Sep 17 00:00:00 2001 From: Raios Date: Tue, 23 Jun 2026 11:28:13 -0300 Subject: [PATCH] fix(gateway): accept html and generic documents --- gateway/platforms/base.py | 14 +++--- gateway/run.py | 6 ++- plugins/platforms/telegram/adapter.py | 11 ++--- tests/gateway/test_document_cache.py | 11 +++++ tests/gateway/test_document_context_note.py | 12 ++--- tests/gateway/test_telegram_group_gating.py | 50 ++++++++++++++++++++- 6 files changed, 84 insertions(+), 20 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index ac1eeef0b893c..0c5f734ebc1ae 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1264,8 +1264,11 @@ def _log_safe_path(path: str) -> str: SUPPORTED_DOCUMENT_TYPES = { ".pdf": "application/pdf", ".md": "text/markdown", + ".html": "text/html", + ".htm": "text/html", ".txt": "text/plain", ".csv": "text/csv", + ".tsv": "text/tab-separated-values", ".log": "text/plain", ".json": "application/json", ".xml": "application/xml", @@ -1520,9 +1523,9 @@ def cache_media_bytes( ``default_kind`` ("image"/"video"/"audio"/"document") biases classification when the extension/MIME are ambiguous — e.g. a Telegram native photo whose - file has no usable name. Any non-image/video/audio file is cached as a - document and surfaced to the agent (arbitrary types get - ``application/octet-stream``); only images that fail validation + file has no usable name. Non-media attachments are cached as documents even + when their extension is not in the common-type map; the map only supplies a + canonical MIME for known types. Images that fail validation (``cache_image_from_bytes`` raises ValueError) return None. """ from tools.credential_files import to_agent_visible_cache_path @@ -1563,9 +1566,8 @@ def cache_media_bytes( # so it can be inspected with terminal / read_file / etc. Authorization to # talk to the agent is the gate that matters — once a user is allowed to # message it, the file-extension allowlist must not silently drop their - # uploads. Known extensions keep their precise MIME; everything else is - # tagged application/octet-stream (or the caller-supplied MIME) so the - # agent knows it's an arbitrary file and reaches for terminal tools. + # uploads. Known extensions keep their precise MIME; everything else keeps + # the caller-supplied MIME or falls back to application/octet-stream. fallback_name = filename or (f"document{ext}" if ext else "document.bin") path = cache_document_from_bytes(data, fallback_name) if ext in SUPPORTED_DOCUMENT_TYPES: diff --git a/gateway/run.py b/gateway/run.py index 0691827bf4580..1860755b1fbe1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1887,8 +1887,10 @@ def _build_document_context_note(display_name: str, agent_path: str, mtype: str) if mtype.startswith("text/"): return ( f"[The user sent a text document: '{display_name}'. " - f"Its content has been included below. " - f"The file is also saved at: {agent_path}]" + f"Its content may be included below if it was small enough to inline. " + f"The original file is saved at: {agent_path}. " + f"If the content is not shown below, or if the request depends on the exact source, " + f"read the saved path with file tools before answering.]" ) return ( f"[The user sent a document: '{display_name}'. It is saved at: {agent_path}. " diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index de08c149a1608..86b5e27ef9d92 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6669,15 +6669,16 @@ async def _handle_media_message(self, update: Update, context: ContextTypes.DEFA # ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead # code — the extension sets are identical. - # Download and cache. Any file type is accepted — authorization - # to message the agent is the gate, not the file extension. - # Known types keep their precise MIME; unknown types are tagged - # application/octet-stream so the agent reaches for terminal tools. + # Download and cache every non-media Telegram document. Any file + # type is accepted — authorization to message the agent is the + # gate, not the file extension. Known extensions get canonical + # MIME types; unknown types keep the caller-supplied MIME or are + # tagged application/octet-stream so the agent reaches for tools. file_obj = await doc.get_file() doc_bytes = await file_obj.download_as_bytearray() raw_bytes = bytes(doc_bytes) cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext or '.bin'}") - mime_type = SUPPORTED_DOCUMENT_TYPES.get(ext) or doc.mime_type or "application/octet-stream" + mime_type = SUPPORTED_DOCUMENT_TYPES.get(ext) or doc_mime or "application/octet-stream" event.media_urls = [cached_path] event.media_types = [mime_type] logger.info("[Telegram] Cached user document at %s (%s)", cached_path, mime_type) diff --git a/tests/gateway/test_document_cache.py b/tests/gateway/test_document_cache.py index 38cf510e28ddf..8bed4f642ab27 100644 --- a/tests/gateway/test_document_cache.py +++ b/tests/gateway/test_document_cache.py @@ -154,8 +154,11 @@ def test_all_extensions_have_mime_types(self): [ ".pdf", ".md", + ".html", + ".htm", ".txt", ".zip", + ".tsv", ".doc", ".docx", ".xls", @@ -238,6 +241,14 @@ def test_unknown_document_no_mime_falls_back_to_octet_stream(self): assert result.kind == "document" assert result.media_type == "application/octet-stream" + def test_html_routes_to_text_document(self): + from gateway.platforms.base import cache_media_bytes + result = cache_media_bytes(b"", filename="plan.html", mime_type="") + assert result is not None + assert result.kind == "document" + assert result.media_type == "text/html" + assert "plan.html" in result.display_name + def test_invalid_image_returns_none(self): from gateway.platforms.base import cache_media_bytes result = cache_media_bytes(b"not an image", filename="x.png", mime_type="image/png") diff --git a/tests/gateway/test_document_context_note.py b/tests/gateway/test_document_context_note.py index e5c787d65a05e..634b8bec79ab5 100644 --- a/tests/gateway/test_document_context_note.py +++ b/tests/gateway/test_document_context_note.py @@ -5,7 +5,8 @@ they'd like you to do with it" — steering it away from extracting the text it is perfectly capable of reading. These tests pin the contract: -- text documents: note confirms the (adapter-)inlined content + records path. +- text documents: note records path and tells the agent to read the saved file + if content was too large to inline. - binary documents (PDF/DOCX/…): note tells the agent to extract the text itself and never tells it to punt back to the user. """ @@ -20,12 +21,13 @@ class TestTextDocumentNote: @pytest.mark.parametrize("mtype", ["text/plain", "text/markdown", "text/csv"]) - def test_text_note_mentions_included_content_and_path(self, mtype): + def test_text_note_mentions_optional_inline_content_and_path(self, mtype): note = _build_document_context_note("notes.txt", "/cache/doc_notes.txt", mtype) assert "text document" in note assert "notes.txt" in note assert "/cache/doc_notes.txt" in note - assert "included below" in note + assert "may be included below" in note + assert "read the saved path" in note class TestBinaryDocumentNote: @@ -52,6 +54,6 @@ def test_binary_note_distinct_from_text_note(self): text_note = _build_document_context_note("a.txt", "/c/a.txt", "text/plain") pdf_note = _build_document_context_note("a.pdf", "/c/a.pdf", "application/pdf") assert text_note != pdf_note - # The text path claims content is inlined; the binary path must not. - assert "included below" in text_note + # The text path allows optional inline content; the binary path must not. + assert "may be included below" in text_note assert "included below" not in pdf_note diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 02362db91ec5a..8e1d8976be6d2 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1007,6 +1007,48 @@ async def _run(): asyncio.run(_run()) +def test_triggered_html_document_is_accepted_and_inlined_when_small(monkeypatch, tmp_path): + async def _run(): + adapter = _make_adapter(require_mention=False) + adapter._max_doc_bytes = 10 * 1024 * 1024 + adapter.handle_message = AsyncMock() + cached_path = tmp_path / "doc_abc_plan.html" + monkeypatch.setattr( + "plugins.platforms.telegram.adapter.cache_document_from_bytes", + lambda _data, _filename: str(cached_path), + ) + html_bytes = b"GDV360 plan" + file_obj = SimpleNamespace( + file_path="documents/plan.html", + download_as_bytearray=AsyncMock(return_value=bytearray(html_bytes)), + ) + document = SimpleNamespace( + file_name="plan.html", + mime_type="text/html", + file_size=len(html_bytes), + get_file=AsyncMock(return_value=file_obj), + ) + update = SimpleNamespace( + update_id=3007, + message=_group_document_message(caption="analisa", document=document), + effective_message=None, + ) + + await adapter._handle_media_message(update, SimpleNamespace()) + + adapter.handle_message.assert_awaited_once() + await_args = adapter.handle_message.await_args + assert await_args is not None + event = await_args.args[0] + assert event.message_type == MessageType.DOCUMENT + assert event.media_urls == [str(cached_path)] + assert event.media_types == ["text/html"] + assert "[Content of plan.html]:" in event.text + assert "GDV360 plan" in event.text + + asyncio.run(_run()) + + # --------------------------------------------------------------------------- # Replied-to media caching # --------------------------------------------------------------------------- @@ -1180,7 +1222,7 @@ async def _run(): asyncio.run(_run()) -def test_unmentioned_unsupported_document_observed_and_cached(monkeypatch): +def test_unmentioned_unknown_document_observed_and_cached(monkeypatch, tmp_path): async def _run(): adapter = _make_adapter( require_mention=True, allowed_chats=["-100"], @@ -1188,7 +1230,8 @@ async def _run(): ) store = _FakeSessionStore() adapter._session_store = store - cache_doc = Mock(return_value="/tmp/program.exe") + cached_path = tmp_path / "doc_abc_program.exe" + cache_doc = Mock(return_value=str(cached_path)) monkeypatch.setattr("gateway.platforms.base.cache_document_from_bytes", cache_doc) file_obj = SimpleNamespace( file_path="documents/program.exe", @@ -1208,6 +1251,9 @@ async def _run(): # extension. The observed message records a path-pointing note. cache_doc.assert_called_once() _, message, _ = store.messages[0] + assert message["observed"] is True assert "program.exe" in message["content"] + assert str(cached_path) in message["content"] + assert "unsupported" not in message["content"].lower() asyncio.run(_run())