From 02bff4215ee79caa65a1938a4548d3f29a12fb2c Mon Sep 17 00:00:00 2001 From: lambertian <288878343+lambertian@users.noreply.github.com> Date: Sat, 30 May 2026 15:00:13 -0400 Subject: [PATCH] fix(simplex): classify inbound documents as MessageType.DOCUMENT Received documents (PDF/office/zip/etc.) arrived as MessageType.TEXT and were silently dropped by the gateway's DOCUMENT-gated attachment block. Classify non-image/non-audio attachments by a real MIME and promote application/* or text/* to MessageType.DOCUMENT, mirroring the existing image/voice handling. _doc_mime_for collapses any non-application/text guess to application/octet-stream so stray image/audio/video extensions don't re-route into the vision/STT pipelines. Relates to #30150. --- plugins/platforms/simplex/adapter.py | 47 +++++++++++++- tests/gateway/test_simplex_plugin.py | 92 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index 9c3d22a429fa9..d2ab3f702d0e5 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -108,6 +108,47 @@ def _is_audio_ext(ext: str) -> bool: return ext.lower() in {".mp3", ".wav", ".ogg", ".m4a", ".aac"} +# Keep aligned with the document-attachment gate in gateway/run.py, which only +# forwards inbound attachments whose MIME starts with "application/" or "text/". +_DOC_EXT_MIME = { + ".pdf": "application/pdf", + ".txt": "text/plain", + ".md": "text/markdown", + ".csv": "text/csv", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html", + ".htm": "text/html", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".doc": "application/msword", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".ppt": "application/vnd.ms-powerpoint", + ".zip": "application/zip", +} + + +def _doc_mime_for(path: str) -> str: + """Return an ``application/*`` or ``text/*`` MIME for a non-image / non-audio + inbound file, so it classifies as a document attachment. + + Any guess outside ``application/*`` / ``text/*`` (e.g. an image/audio/video + extension that isn't in the adapter's image/audio extension allowlist, such + as .heic or .flac) collapses to ``application/octet-stream`` rather than + re-routing the file into the image/voice pipelines. + """ + ext = os.path.splitext(path)[1].lower() + if ext in _DOC_EXT_MIME: + return _DOC_EXT_MIME[ext] + import mimetypes + + guessed, _ = mimetypes.guess_type(path) + if guessed and (guessed.startswith("application/") or guessed.startswith("text/")): + return guessed + return "application/octet-stream" + + # --------------------------------------------------------------------------- # SimpleX Adapter # --------------------------------------------------------------------------- @@ -387,7 +428,7 @@ async def _handle_new_chat_item(self, wrapper: dict) -> None: elif _is_audio_ext("." + ext): media_types.append("audio/" + ext) else: - media_types.append("application/octet-stream") + media_types.append(_doc_mime_for(cached)) media_urls.append(cached) except Exception: logger.exception("SimpleX: failed to fetch file %s", file_id) @@ -415,6 +456,10 @@ async def _handle_new_chat_item(self, wrapper: dict) -> None: msg_type = MessageType.VOICE elif any(mt.startswith("image/") for mt in media_types): msg_type = MessageType.PHOTO + elif any( + mt.startswith(("application/", "text/")) for mt in media_types + ): + msg_type = MessageType.DOCUMENT event_obj = MessageEvent( source=source, diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py index 1048168aa6e64..da120a33dd864 100644 --- a/tests/gateway/test_simplex_plugin.py +++ b/tests/gateway/test_simplex_plugin.py @@ -344,3 +344,95 @@ def test_register_calls_register_platform(): assert callable(kwargs["setup_fn"]) # SimpleX uses opaque IDs only — no PII to redact. assert kwargs["pii_safe"] is True + + +# --------------------------------------------------------------------------- +# 9. Inbound: documents (PDF/office/etc.) classified as MessageType.DOCUMENT +# so gateway/run.py forwards them as attachments instead of dropping as TEXT. +# --------------------------------------------------------------------------- + +def test_doc_mime_for_maps_known_and_unknown_extensions(): + f = _simplex._doc_mime_for + assert f("/x/report.pdf") == "application/pdf" + assert f("/x/notes.txt") == "text/plain" + # Unknown extension still yields an application/* MIME, so it routes as DOCUMENT. + assert f("/x/archive.unknownext").startswith("application/") + # Image/audio/video extensions outside the allowlist must NOT yield image/* or + # audio/* (which would re-route to PHOTO/VOICE) — they collapse to application/*. + assert f("/x/pic.heic").startswith("application/") + assert f("/x/song.flac").startswith("application/") + assert f("/x/clip.mp4").startswith("application/") + + +@pytest.mark.asyncio +async def test_inbound_document_classified_as_document(): + from gateway.config import PlatformConfig + + MessageType = _simplex.MessageType + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + async def fake_fetch(file_id, file_name): + return "/tmp/simplex-test/report.pdf" + + adapter._fetch_file = fake_fetch # type: ignore + + captured = {} + + async def capture(event): + captured["event"] = event + + adapter.handle_message = capture # type: ignore + + wrapper = { + "chatInfo": {"type": "direct", "contact": {"contactId": 7, "localDisplayName": "tester"}}, + "chatItem": { + "content": {"msgContent": {"type": "file", "text": ""}}, + "meta": {"itemStatus": {"type": "rcvNew"}, "itemTs": "2026-05-30T00:00:00Z"}, + "file": {"fileId": 99, "fileName": "report.pdf", "fileStatus": "rcvComplete"}, + }, + } + + await adapter._handle_new_chat_item(wrapper) + + event = captured["event"] + assert event.message_type == MessageType.DOCUMENT + assert "application/pdf" in event.media_types + + +@pytest.mark.asyncio +async def test_inbound_unallowlisted_media_routes_to_document_not_photo_or_voice(): + """Files whose extension is outside the image/audio allowlist (heic/svg/flac/ + mp4) must classify as DOCUMENT, never PHOTO/VOICE — the mimetypes fallback + must not re-route them into the vision/STT pipelines.""" + from gateway.config import PlatformConfig + + MessageType = _simplex.MessageType + for fname in ("photo.heic", "icon.svg", "song.flac", "clip.mp4"): + cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"}) + adapter = SimplexAdapter(cfg) + + async def fake_fetch(file_id, file_name, _f=fname): + return "/tmp/simplex-test/" + _f + + adapter._fetch_file = fake_fetch # type: ignore + + captured = {} + + async def capture(event): + captured["event"] = event + + adapter.handle_message = capture # type: ignore + + wrapper = { + "chatInfo": {"type": "direct", "contact": {"contactId": 7}}, + "chatItem": { + "content": {"msgContent": {"type": "file", "text": ""}}, + "meta": {"itemStatus": {"type": "rcvNew"}}, + "file": {"fileId": 1, "fileName": fname, "fileStatus": "rcvComplete"}, + }, + } + + await adapter._handle_new_chat_item(wrapper) + mt = captured["event"].message_type + assert mt == MessageType.DOCUMENT, f"{fname} classified as {mt}, expected DOCUMENT"