Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion plugins/platforms/simplex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
92 changes: 92 additions & 0 deletions tests/gateway/test_simplex_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"