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
42 changes: 23 additions & 19 deletions plugins/platforms/simplex/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,21 +501,24 @@ async def send(
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a text message to a contact or group."""
corr_id = self._make_corr_id()
"""Send a text message to a contact or group.

Long content is split into ``MAX_MESSAGE_LENGTH``-sized chunks via the
base :meth:`truncate_message` helper and sent in order; short content
is delivered as a single message.
"""
if chat_id.startswith("group:"):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {content}"
prefix = f"#[{chat_id[6:]}] "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Current main replaced this bracket group command with structured /_send #<id> json because #[<id>] is parsed as a display-name lookup and can silently miss the intended group. Preserve that current group formatter and emit one JSON payload per chunk instead.

else:
cmd_str = f"@[{chat_id}] {content}"
prefix = f"@[{chat_id}] "

payload = {
"corrId": corr_id,
"cmd": cmd_str,
}
for chunk in self.truncate_message(content, MAX_MESSAGE_LENGTH):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please also set splits_long_messages = True on SimplexAdapter. gateway/delivery.py:403-451 otherwise treats SimpleX as non-chunking and truncates oversized cron output to 4,000 characters before this loop runs.

payload = {
"corrId": self._make_corr_id(),
"cmd": f"{prefix}{chunk}",
}
await self._send_ws(payload)

await self._send_ws(payload)
return SendResult(success=True)

async def send_typing(self, chat_id: str, metadata=None) -> None:
Expand Down Expand Up @@ -640,19 +643,20 @@ async def _standalone_send(

try:
if chat_id.startswith("group:"):
group_id = chat_id[6:]
cmd_str = f"#[{group_id}] {message}"
prefix = f"#[{chat_id[6:]}] "
else:
cmd_str = f"@[{chat_id}] {message}"
prefix = f"@[{chat_id}] "

payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}",
"cmd": cmd_str,
}
chunks = BasePlatformAdapter.truncate_message(message, MAX_MESSAGE_LENGTH)

async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws:
await ws.send(json.dumps(payload))
# Give the daemon a moment to process the command before closing.
for i, chunk in enumerate(chunks):
payload = {
"corrId": f"hermes-snd-{int(time.time() * 1000)}-{i}",
"cmd": f"{prefix}{chunk}",
}
await ws.send(json.dumps(payload))
# Give the daemon a moment to process the command(s) before closing.
await asyncio.sleep(0.5)

return {"success": True, "platform": "simplex", "chat_id": chat_id}
Expand Down
60 changes: 60 additions & 0 deletions tests/gateway/test_simplex_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import json
import re
from unittest.mock import AsyncMock, MagicMock

import pytest
Expand Down Expand Up @@ -234,6 +235,65 @@ async def test_send_group():
assert result.success is True


@pytest.mark.asyncio
async def test_send_short_content_single_send():
"""Content within the limit is delivered as exactly one WS send."""
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)

mock_ws = AsyncMock()
adapter._ws = mock_ws

result = await adapter.send("contact-42", "short message")
mock_ws.send.assert_called_once()
assert result.success is True


@pytest.mark.asyncio
async def test_send_long_content_chunks_into_ordered_sends():
"""Content longer than the max is split into multiple ordered sends,
each chunk staying within the advertised limit."""
from gateway.config import PlatformConfig
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
adapter = SimplexAdapter(cfg)

mock_ws = AsyncMock()
adapter._ws = mock_ws

max_len = _simplex.MAX_MESSAGE_LENGTH
# Plain text (no code fences) longer than the limit -> several chunks.
long_text = "word " * (max_len // 2)
assert len(long_text) > max_len

result = await adapter.send("contact-42", long_text)
assert result.success is True
assert mock_ws.send.call_count > 1

total = mock_ws.send.call_count
bodies = []
for i, call in enumerate(mock_ws.send.call_args_list):
payload = json.loads(call[0][0])
# Every chunk is addressed to the same contact, in order.
assert payload["cmd"].startswith("@[contact-42] ")
chunk = payload["cmd"][len("@[contact-42] "):]
# Each chunk body must respect the per-message limit.
assert len(chunk) <= max_len
# Multi-part indicator appended by the base helper (whatever its exact
# spacing): a trailing "(n/N)" marker must be present, in send order.
assert re.search(r"\(\d+/\d+\)\s*$", chunk)
assert re.search(rf"\({i + 1}/{total}\)\s*$", chunk)
# Drop the trailing part-indicator to recover the original body text.
bodies.append(re.sub(r"\s*\(\d+/\d+\)\s*$", "", chunk))

# Content coverage: reassembling the indicator-stripped chunk bodies must
# account for every word of the original message. truncate_message splits
# on word boundaries and lstrips inter-chunk whitespace, so word-for-word
# reassembly is reliable even if exact byte concatenation is not.
reassembled = " ".join(bodies).split()
assert reassembled == long_text.split()


@pytest.mark.asyncio
async def test_send_when_ws_not_connected_does_not_crash():
from gateway.config import PlatformConfig
Expand Down