Skip to content
Open
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
77 changes: 67 additions & 10 deletions gateway/platforms/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import base64
import json
import logging
import mimetypes
import os
import random
import time
Expand All @@ -33,6 +34,7 @@
MessageType,
ProcessingOutcome,
SendResult,
SUPPORTED_DOCUMENT_TYPES,
cache_image_from_bytes,
cache_audio_from_bytes,
cache_document_from_bytes,
Expand Down Expand Up @@ -120,6 +122,59 @@ def _ext_to_mime(ext: str) -> str:
return _EXT_TO_MIME.get(ext.lower(), "application/octet-stream")


_DOCUMENT_MIME_TO_EXT: Dict[str, str] = {}
for _document_ext, _document_mime in SUPPORTED_DOCUMENT_TYPES.items():
_DOCUMENT_MIME_TO_EXT.setdefault(str(_document_mime).lower(), _document_ext)


def _mime_to_ext(content_type: str) -> str:
"""Best-effort extension lookup for inbound attachments."""
normalized = str(content_type or "").split(";", 1)[0].strip().lower()
if not normalized:
return ""
explicit = _DOCUMENT_MIME_TO_EXT.get(normalized)
if explicit:
return explicit
guessed = mimetypes.guess_extension(normalized, strict=False) or ""
return guessed.lower()


def _build_document_cache_name(attachment: Dict[str, Any], guessed_ext: str) -> str:
"""Return a stable cache filename for non-image Signal attachments."""
for key in ("filename", "fileName", "name", "originalFilename"):
raw_name = attachment.get(key)
if isinstance(raw_name, str) and raw_name.strip():
return raw_name.strip()

content_type = attachment.get("contentType")
derived_ext = _mime_to_ext(content_type) or guessed_ext or ".bin"
if not derived_ext.startswith("."):
derived_ext = f".{derived_ext}"
return f"attachment{derived_ext}"


def _detect_inbound_message_type(media_types: List[str]) -> MessageType:
"""Classify inbound Signal attachments for gateway routing.

Signal attachments already carry MIME types. Anything that's not an
image, audio, or video should surface as a document so the gateway
injects the saved file path into the agent context instead of silently
treating the turn as plain text.
"""
normalized = [str(mtype or "").lower() for mtype in media_types if str(mtype or "").strip()]
if not normalized:
return MessageType.TEXT
if any(not mtype.startswith(("image/", "audio/", "video/")) for mtype in normalized):
return MessageType.DOCUMENT
if any(mtype.startswith("video/") for mtype in normalized):
return MessageType.VIDEO
if any(mtype.startswith("audio/") for mtype in normalized):
return MessageType.VOICE
if any(mtype.startswith("image/") for mtype in normalized):
return MessageType.PHOTO
return MessageType.TEXT


def _render_mentions(text: str, mentions: list) -> str:
"""Replace Signal mention placeholders (\\uFFFC) with readable @identifiers.

Expand Down Expand Up @@ -536,7 +591,7 @@ async def _handle_envelope(self, envelope: dict) -> None:
logger.warning("Signal: attachment too large (%d bytes), skipping", att_size)
continue
try:
cached_path, ext = await self._fetch_attachment(att_id)
cached_path, ext = await self._fetch_attachment(att_id, attachment_meta=att)
if cached_path:
# Use contentType from Signal if available, else map from extension
content_type = att.get("contentType") or _ext_to_mime(ext)
Expand Down Expand Up @@ -568,13 +623,8 @@ async def _handle_envelope(self, envelope: dict) -> None:
chat_id_alt=group_id if is_group else None,
)

# Determine message type from media
msg_type = MessageType.TEXT
if media_types:
if any(mt.startswith("audio/") for mt in media_types):
msg_type = MessageType.VOICE
elif any(mt.startswith("image/") for mt in media_types):
msg_type = MessageType.PHOTO
# Determine message type from media.
msg_type = _detect_inbound_message_type(media_types)

# Parse timestamp from envelope data (milliseconds since epoch)
ts_ms = envelope_data.get("timestamp", 0)
Expand Down Expand Up @@ -668,7 +718,12 @@ async def _resolve_recipient(self, chat_id: str) -> str:
# Attachment Handling
# ------------------------------------------------------------------

async def _fetch_attachment(self, attachment_id: str) -> tuple:
async def _fetch_attachment(
self,
attachment_id: str,
*,
attachment_meta: Optional[Dict[str, Any]] = None,
) -> tuple:
"""Fetch an attachment via JSON-RPC and cache it. Returns (path, ext)."""
result = await self._rpc("getAttachment", {
"account": self.account,
Expand All @@ -694,7 +749,9 @@ async def _fetch_attachment(self, attachment_id: str) -> tuple:
elif _is_audio_ext(ext):
path = cache_audio_from_bytes(raw_data, ext)
else:
path = cache_document_from_bytes(raw_data, ext)
cache_name = _build_document_cache_name(attachment_meta or {}, ext)
path = cache_document_from_bytes(raw_data, cache_name)
ext = Path(cache_name).suffix.lower() or ext

return path, ext

Expand Down
190 changes: 190 additions & 0 deletions tests/gateway/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ def test_check_requirements(self, monkeypatch):
monkeypatch.setenv("SIGNAL_ACCOUNT", "+15551234567")
assert check_signal_requirements() is True

def test_detect_inbound_message_type_image(self):
from gateway.platforms.base import MessageType
from gateway.platforms.signal import _detect_inbound_message_type

assert _detect_inbound_message_type(["image/png"]) == MessageType.PHOTO

def test_detect_inbound_message_type_video(self):
from gateway.platforms.base import MessageType
from gateway.platforms.signal import _detect_inbound_message_type

assert _detect_inbound_message_type(["video/mp4"]) == MessageType.VIDEO

def test_detect_inbound_message_type_pdf(self):
from gateway.platforms.base import MessageType
from gateway.platforms.signal import _detect_inbound_message_type

assert _detect_inbound_message_type(["application/pdf"]) == MessageType.DOCUMENT

def test_detect_inbound_message_type_document_wins_for_mixed_media(self):
from gateway.platforms.base import MessageType
from gateway.platforms.signal import _detect_inbound_message_type

assert _detect_inbound_message_type(
["image/png", "application/pdf"]
) == MessageType.DOCUMENT

def test_render_mentions(self):
from gateway.platforms.signal import _render_mentions
text = "Hello \uFFFC, how are you?"
Expand Down Expand Up @@ -274,11 +300,135 @@ async def test_fetch_attachment_handles_dict_response(self, monkeypatch):
assert path == "/tmp/test.pdf"
assert ext == ".pdf"

@pytest.mark.asyncio
async def test_fetch_attachment_preserves_docx_filename(self, monkeypatch):
adapter = _make_signal_adapter(monkeypatch)

docx_like_data = b"PK\x03\x04" + b"\x00" * 100
b64_data = base64.b64encode(docx_like_data).decode()

adapter._rpc, _ = _stub_rpc({"data": b64_data})

with patch("gateway.platforms.signal.cache_document_from_bytes", return_value="/tmp/proposal.docx") as cache_doc:
path, ext = await adapter._fetch_attachment(
"docx-789",
attachment_meta={
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"filename": "proposal.docx",
},
)

assert path == "/tmp/proposal.docx"
assert ext == ".docx"
cache_doc.assert_called_once_with(docx_like_data, "proposal.docx")

@pytest.mark.asyncio
async def test_fetch_attachment_uses_content_type_when_filename_missing(self, monkeypatch):
adapter = _make_signal_adapter(monkeypatch)

docx_like_data = b"PK\x03\x04" + b"\x00" * 100
b64_data = base64.b64encode(docx_like_data).decode()

adapter._rpc, _ = _stub_rpc({"data": b64_data})

with patch("gateway.platforms.signal.cache_document_from_bytes", return_value="/tmp/attachment.docx") as cache_doc:
path, ext = await adapter._fetch_attachment(
"docx-790",
attachment_meta={
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
)

assert path == "/tmp/attachment.docx"
assert ext == ".docx"
cache_doc.assert_called_once_with(docx_like_data, "attachment.docx")


# ---------------------------------------------------------------------------
# Session Source
# ---------------------------------------------------------------------------

class TestSignalInboundAttachments:
@pytest.mark.asyncio
async def test_handle_envelope_marks_pdf_attachment_as_document(self, monkeypatch):
from gateway.platforms.base import MessageType

adapter = _make_signal_adapter(monkeypatch)
captured = {}

async def fake_handle(event):
captured["event"] = event

adapter.handle_message = fake_handle
adapter._fetch_attachment = AsyncMock(return_value=("/tmp/inbound-document.pdf", ".pdf"))

await adapter._handle_envelope({
"envelope": {
"sourceNumber": "+15550001111",
"sourceUuid": "uuid-sender",
"sourceName": "Tester",
"timestamp": 1000000000,
"dataMessage": {
"message": "can you read this?",
"attachments": [
{
"id": "att-pdf-1",
"contentType": "application/pdf",
"size": 1024,
}
],
},
}
})

event = captured["event"]
assert event.text == "can you read this?"
assert event.message_type == MessageType.DOCUMENT
assert event.media_urls == ["/tmp/inbound-document.pdf"]
assert event.media_types == ["application/pdf"]

@pytest.mark.asyncio
async def test_handle_envelope_marks_docx_attachment_as_document(self, monkeypatch):
from gateway.platforms.base import MessageType

adapter = _make_signal_adapter(monkeypatch)
captured = {}

async def fake_handle(event):
captured["event"] = event

adapter.handle_message = fake_handle
adapter._fetch_attachment = AsyncMock(return_value=("/tmp/inbound-document.docx", ".docx"))

await adapter._handle_envelope({
"envelope": {
"sourceNumber": "+15550001111",
"sourceUuid": "uuid-sender",
"sourceName": "Tester",
"timestamp": 1000000000,
"dataMessage": {
"message": "word doc attached",
"attachments": [
{
"id": "att-docx-1",
"contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"filename": "proposal.docx",
"size": 2048,
}
],
},
}
})

event = captured["event"]
assert event.text == "word doc attached"
assert event.message_type == MessageType.DOCUMENT
assert event.media_urls == ["/tmp/inbound-document.docx"]
assert event.media_types == [
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
]


class TestSignalSessionSource:
def test_session_source_alt_fields(self):
from gateway.session import SessionSource
Expand Down Expand Up @@ -1114,6 +1264,46 @@ async def fake_handle(event):
assert event.reply_to_message_id == "123"
assert event.reply_to_text is None


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This redefines the earlier TestSignalInboundAttachments class added in this PR. Python replaces the first class binding during module import, so the earlier DOCX envelope test is not collected. Merge these cases into one uniquely defined class.

class TestSignalInboundAttachments:
@pytest.mark.asyncio
async def test_handle_envelope_marks_pdf_attachment_as_document(self, monkeypatch):
from gateway.platforms.base import MessageType

adapter = _make_signal_adapter(monkeypatch)
captured = {}

async def fake_handle(event):
captured["event"] = event

adapter.handle_message = fake_handle
adapter._fetch_attachment = AsyncMock(return_value=("/tmp/inbound-document.pdf", ".pdf"))

await adapter._handle_envelope({
"envelope": {
"sourceNumber": "+15550001111",
"sourceUuid": "uuid-sender",
"sourceName": "Tester",
"timestamp": 1000000000,
"dataMessage": {
"message": "can you read this?",
"attachments": [
{
"id": "att-pdf-1",
"contentType": "application/pdf",
"size": 1024,
}
],
},
}
})

event = captured["event"]
assert event.text == "can you read this?"
assert event.message_type == MessageType.DOCUMENT
assert event.media_urls == ["/tmp/inbound-document.pdf"]
assert event.media_types == ["application/pdf"]

# ---------------------------------------------------------------------------
# _rpc rate-limit detection
# ---------------------------------------------------------------------------
Expand Down