diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index 9c3d22a429fa..116874efc1e8 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -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:]}] " 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): + 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: @@ -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} diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py index 1048168aa6e6..ed59c0612390 100644 --- a/tests/gateway/test_simplex_plugin.py +++ b/tests/gateway/test_simplex_plugin.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import re from unittest.mock import AsyncMock, MagicMock import pytest @@ -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