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
9 changes: 5 additions & 4 deletions plugins/platforms/simplex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,11 +575,12 @@ async def _handle_chat_item(self, chat_item: dict) -> None:
if not ext and file_name:
ext = Path(file_name).suffix.lower()

# Voice notes typically arrive before the file finishes
# downloading. Defer the message until rcvFileComplete fires.
if not file_path and _is_audio_ext(ext) and file_id is not None:
# File not yet downloaded β€” defer the message until
# rcvFileComplete fires. Applies to all file types (images,
# documents, audio) transferred via XFTP, not just voice notes.
if not file_path and file_id is not None:
logger.info(
"SimpleX: voice file %d not yet received, accepting transfer",
"SimpleX: file %d not yet received, accepting transfer",
file_id,
)
self._pending_file_transfers[file_id] = chat_item
Expand Down
76 changes: 76 additions & 0 deletions tests/gateway/test_simplex_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,3 +467,79 @@ async def _capture(event):

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


def _make_pending_file_chat_item(file_name: str, file_id: int = 42) -> dict:
"""Minimal direct-chat rcvMsgContent item carrying a file NOT yet downloaded.

The ``fileSource`` has no ``filePath`` key, simulating an XFTP transfer
that is still in progress (the daemon hasn't written the file to disk).
"""
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": file_id,
"fileName": file_name,
"fileSource": {}, # no filePath β†’ transfer not complete
},
},
}


@pytest.mark.asyncio
async def test_pending_image_file_accepts_transfer():
"""An image that hasn't finished downloading must be accepted via
/freceive and deferred until rcvFileComplete β€” not silently dropped."""
from gateway.config import PlatformConfig

cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
sent_commands: list = []
adapter._send_fire_and_forget = lambda cmd: (
sent_commands.append(cmd) or __import__("asyncio").sleep(0)
)

chat_item = _make_pending_file_chat_item("photo.jpg", file_id=99)
await adapter._handle_chat_item(chat_item)

# The adapter must accept the transfer …
assert any("/freceive 99" in c for c in sent_commands), (
f"Expected /freceive 99, got: {sent_commands}"
)
# … and defer the chat item until rcvFileComplete.
assert 99 in adapter._pending_file_transfers
# No message dispatched yet (deferred).
assert not hasattr(adapter, "_dispatched") or not getattr(adapter, "_dispatched", None)


@pytest.mark.asyncio
async def test_pending_document_file_accepts_transfer():
"""A PDF/document that hasn't finished downloading must also be accepted
via /freceive β€” the fix for #55180 removes the audio-only guard."""
from gateway.config import PlatformConfig

cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)
sent_commands: list = []
adapter._send_fire_and_forget = lambda cmd: (
sent_commands.append(cmd) or __import__("asyncio").sleep(0)
)

chat_item = _make_pending_file_chat_item("report.pdf", file_id=77)
await adapter._handle_chat_item(chat_item)

assert any("/freceive 77" in c for c in sent_commands), (
f"Expected /freceive 77, got: {sent_commands}"
)
assert 77 in adapter._pending_file_transfers

Loading