Skip to content
9 changes: 8 additions & 1 deletion gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,8 +470,15 @@ async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None:
for att in attachments:
media_urls.append(att["path"])
media_types.append(att["media_type"])
if att["type"] == "image":
if att["type"] == "image" and msg_type == MessageType.TEXT:
msg_type = MessageType.PHOTO
elif att["type"] == "document":
# Document wins over PHOTO for mixed attachments: run.py's
# image handling keys off the per-path image/* mime type
# regardless of message_type, but document-context injection
# gates strictly on MessageType.DOCUMENT — so DOCUMENT is the
# only classification that surfaces both.
msg_type = MessageType.DOCUMENT

# Store thread context for reply threading
self._thread_context[sender_addr] = {
Expand Down
8 changes: 8 additions & 0 deletions gateway/platforms/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,14 @@ async def _handle_envelope(self, envelope: dict) -> None:
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
elif any(mt.startswith("video/") for mt in media_types):
msg_type = MessageType.VIDEO
else:
# Catch-all: application/*, text/*, and unknown MIME types are
# treated as documents so run.py's document-context injection
# surfaces the cached file path to the agent (same pattern as
# WhatsApp/Slack/BlueBubbles/Mattermost).
msg_type = MessageType.DOCUMENT

# Parse timestamp from envelope data (milliseconds since epoch)
ts_ms = envelope_data.get("timestamp", 0)
Expand Down
5 changes: 5 additions & 0 deletions plugins/platforms/simplex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,11 @@ async def _handle_chat_item(self, chat_item: dict) -> None:
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
else:
# Catch-all: non-image/non-audio files (tagged
# application/octet-stream above) are documents so run.py's
# document-context injection surfaces the file to the agent.
msg_type = MessageType.DOCUMENT

# Timestamp
ts_str = meta.get("itemTs") or meta.get("createdAt", "")
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
"dirtyren@users.noreply.github.com": "dirtyren",
"kdunn926@gmail.com": "kdunn926",
"mvanhorn@MacBook-Pro.local": "mvanhorn",
"470766206@qq.com": "youjunxiaji",
"mharris@parallel.ai": "NormallyGaussian",
Expand Down
62 changes: 62 additions & 0 deletions tests/gateway/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,68 @@ async def capture_handle(event):
self.assertEqual(captured_events[0].message_type, MessageType.PHOTO)
self.assertEqual(captured_events[0].media_urls, ["/tmp/img.jpg"])

def test_document_attachment_sets_document_type(self):
"""Email with a document attachment must set DOCUMENT so run.py injects file context."""
import asyncio
from gateway.platforms.base import MessageType
adapter = self._make_adapter()
captured_events = []

async def capture_handle(event):
captured_events.append(event)

adapter.handle_message = capture_handle

msg_data = {
"uid": b"6",
"sender_addr": "user@test.com",
"sender_name": "User",
"subject": "Re: report",
"message_id": "<msg6@test.com>",
"in_reply_to": "",
"body": "See attached",
"attachments": [{"path": "/tmp/report.pdf", "filename": "report.pdf", "type": "document", "media_type": "application/pdf"}],
"date": "",
}

asyncio.run(adapter._dispatch_message(msg_data))
self.assertEqual(len(captured_events), 1)
self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)
self.assertEqual(captured_events[0].media_urls, ["/tmp/report.pdf"])

def test_mixed_image_and_document_prefers_document(self):
"""DOCUMENT wins for mixed attachments — image handling keys off per-path
mime types, but document injection gates strictly on MessageType.DOCUMENT."""
import asyncio
from gateway.platforms.base import MessageType
adapter = self._make_adapter()
captured_events = []

async def capture_handle(event):
captured_events.append(event)

adapter.handle_message = capture_handle

msg_data = {
"uid": b"7",
"sender_addr": "user@test.com",
"sender_name": "User",
"subject": "Re: both",
"message_id": "<msg7@test.com>",
"in_reply_to": "",
"body": "Photo and PDF",
"attachments": [
{"path": "/tmp/img.jpg", "filename": "img.jpg", "type": "image", "media_type": "image/jpeg"},
{"path": "/tmp/report.pdf", "filename": "report.pdf", "type": "document", "media_type": "application/pdf"},
],
"date": "",
}

asyncio.run(adapter._dispatch_message(msg_data))
self.assertEqual(len(captured_events), 1)
self.assertEqual(captured_events[0].message_type, MessageType.DOCUMENT)
self.assertEqual(len(captured_events[0].media_urls), 2)

def test_source_built_correctly(self):
"""Session source should have correct chat_id and user info."""
import asyncio
Expand Down
147 changes: 147 additions & 0 deletions tests/gateway/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,153 @@ def test_signal_has_all_media_methods(self, monkeypatch):
assert type(adapter).send_image is not BasePlatformAdapter.send_image


# ---------------------------------------------------------------------------
# Inbound attachment message type classification
# ---------------------------------------------------------------------------

def _make_dm_envelope(sender: str, attachments: list, text: str = "") -> dict:
"""Build a minimal signal-cli DM envelope with the given attachments."""
return {
"envelope": {
"sourceNumber": sender,
"sourceName": "Test User",
"sourceUuid": "aaaaaaaa-0000-0000-0000-000000000001",
"timestamp": 1700000000000,
"dataMessage": {
"timestamp": 1700000000000,
"message": text,
"expiresInSeconds": 0,
"viewOnce": False,
"attachments": attachments,
},
}
}


class TestSignalInboundMessageTypeClassification:
"""_handle_envelope must set MessageType.DOCUMENT for application/* and text/* attachments.

Before the fix, PDFs and other documents left msg_type as MessageType.TEXT,
so run.py's document-context injection (which gates on MessageType.DOCUMENT)
silently dropped the file and the agent never saw it.
"""

async def _dispatch_single_attachment(self, monkeypatch, content_type: str,
att_id: str, fetch_path: str, fetch_ext: str):
"""Helper: run _handle_envelope with one attachment and return the dispatched event."""
envelope = _make_dm_envelope(
sender="+15559876543",
attachments=[{
"contentType": content_type,
"id": att_id,
"size": 1024,
"filename": None,
"width": None,
"height": None,
"caption": None,
"uploadTimestamp": 1700000000000,
}],
)
adapter = _make_signal_adapter(monkeypatch)
adapter._rpc, _ = _stub_rpc(None)
dispatched = []

async def _fake_handle_message(event):
dispatched.append(event)

adapter.handle_message = _fake_handle_message
adapter._fetch_attachment = AsyncMock(return_value=(fetch_path, fetch_ext))
await adapter._handle_envelope(envelope)
assert dispatched, "_handle_envelope did not dispatch any event"
return dispatched[0]

@pytest.mark.asyncio
async def test_pdf_attachment_sets_document_type(self, monkeypatch):
"""A PDF attachment (application/pdf) must produce MessageType.DOCUMENT, not TEXT."""
from gateway.platforms.base import MessageType

event = await self._dispatch_single_attachment(
monkeypatch,
content_type="application/pdf",
att_id="6zLO3b-6Yf3zVWeLDctA.pdf",
fetch_path="/tmp/report.pdf",
fetch_ext=".pdf",
)

assert event.message_type == MessageType.DOCUMENT, (
f"Expected DOCUMENT, got {event.message_type}. "
"PDFs must be classified as DOCUMENT so run.py injects file context."
)
assert "/tmp/report.pdf" in event.media_urls

@pytest.mark.asyncio
async def test_text_plain_attachment_sets_document_type(self, monkeypatch):
"""A text/plain attachment must produce MessageType.DOCUMENT, not TEXT."""
from gateway.platforms.base import MessageType

event = await self._dispatch_single_attachment(
monkeypatch,
content_type="text/plain",
att_id="notes.txt",
fetch_path="/tmp/notes.txt",
fetch_ext=".txt",
)

assert event.message_type == MessageType.DOCUMENT, (
f"Expected DOCUMENT, got {event.message_type}. "
"text/plain must be classified as DOCUMENT so run.py injects file context."
)

@pytest.mark.asyncio
async def test_text_html_attachment_sets_document_type(self, monkeypatch):
"""A text/html attachment must produce MessageType.DOCUMENT (covers the text/* wildcard)."""
from gateway.platforms.base import MessageType

event = await self._dispatch_single_attachment(
monkeypatch,
content_type="text/html",
att_id="page.html",
fetch_path="/tmp/page.html",
fetch_ext=".html",
)

assert event.message_type == MessageType.DOCUMENT, (
f"Expected DOCUMENT, got {event.message_type}. "
"text/html must be classified as DOCUMENT so run.py injects file context."
)

@pytest.mark.asyncio
async def test_video_attachment_sets_video_type(self, monkeypatch):
"""A video/mp4 attachment must produce MessageType.VIDEO."""
from gateway.platforms.base import MessageType

event = await self._dispatch_single_attachment(
monkeypatch,
content_type="video/mp4",
att_id="clip.mp4",
fetch_path="/tmp/clip.mp4",
fetch_ext=".mp4",
)

assert event.message_type == MessageType.VIDEO

@pytest.mark.asyncio
async def test_unknown_mime_attachment_falls_back_to_document(self, monkeypatch):
"""Unknown/exotic MIME types fall through to DOCUMENT (catch-all),
matching the WhatsApp/Slack/BlueBubbles classification pattern."""
from gateway.platforms.base import MessageType

event = await self._dispatch_single_attachment(
monkeypatch,
content_type="chemical/x-pdb",
att_id="molecule.pdb",
fetch_path="/tmp/molecule.pdb",
fetch_ext=".pdb",
)

assert event.message_type == MessageType.DOCUMENT


# ---------------------------------------------------------------------------
# send_document now routes through _send_attachment (#5105 bonus)
# ---------------------------------------------------------------------------
Expand Down
71 changes: 71 additions & 0 deletions tests/gateway/test_simplex_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,74 @@ 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


# ---------------------------------------------------------------------------
# Inbound attachment message type classification
# ---------------------------------------------------------------------------

def _make_file_chat_item(file_path: str, file_name: str) -> dict:
"""Minimal direct-chat rcvMsgContent item carrying a completed file."""
return {
"chatInfo": {
"type": "direct",
"contact": {"contactId": 42, "localDisplayName": "tester"},
},
"chatItem": {
"chatDir": {"type": "directRcv"},
"meta": {"itemTs": "2026-01-01T00:00:00Z"},
"content": {
"type": "rcvMsgContent",
"msgContent": {"type": "file", "text": "here you go"},
},
"file": {
"fileId": 7,
"fileName": file_name,
"fileSource": {"filePath": file_path},
},
},
}


@pytest.mark.asyncio
async def test_document_file_sets_document_type():
"""A non-image/non-audio file must classify as DOCUMENT, not TEXT,
so run.py's document-context injection surfaces the path to the agent."""
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageType

cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
dispatched = []

async def _capture(event):
dispatched.append(event)

adapter.handle_message = _capture
await adapter._handle_chat_item(_make_file_chat_item("/tmp/report.pdf", "report.pdf"))

assert dispatched, "_handle_chat_item did not dispatch any event"
assert dispatched[0].message_type == MessageType.DOCUMENT
assert dispatched[0].media_urls == ["/tmp/report.pdf"]
assert dispatched[0].media_types == ["application/octet-stream"]


@pytest.mark.asyncio
async def test_image_file_still_sets_photo_type():
"""Regression guard: image files keep classifying as PHOTO after the
document catch-all was added."""
from gateway.config import PlatformConfig
from gateway.platforms.base import MessageType

cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
dispatched = []

async def _capture(event):
dispatched.append(event)

adapter.handle_message = _capture
await adapter._handle_chat_item(_make_file_chat_item("/tmp/pic.jpg", "pic.jpg"))

assert dispatched, "_handle_chat_item did not dispatch any event"
assert dispatched[0].message_type == MessageType.PHOTO
Loading