feat(gateway): add SimpleX Chat adapter - #4870
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces SimpleX Chat as a new first-class messaging gateway platform in Hermes, including adapter implementation, CLI/config wiring, send_message support, tests, and end-user documentation.
Changes:
- Added a new
SimplexAdapterwith WebSocket event handling, DM/group routing, and file/voice send support. - Integrated SimpleX across gateway tooling (
send_message, cron delivery targets, channel directory, CLI setup/status/toolsets). - Added SimpleX documentation and platform listings, plus a dedicated gateway test suite.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
website/docs/user-guide/messaging/simplex.md |
New SimpleX setup, access control, features, and troubleshooting guide. |
website/docs/user-guide/messaging/index.md |
Adds SimpleX to platform overview, diagrams, and setup links. |
uv.lock |
Updates lockfile metadata/deps for new features/extras. |
toolsets.py |
Adds hermes-simplex toolset and includes it in hermes-gateway. |
tools/send_message_tool.py |
Adds SimpleX platform mapping/target parsing and SimpleX send path (incl. media). |
tools/cronjob_tools.py |
Documents simplex as a supported cron delivery target. |
tests/gateway/test_simplex.py |
New test coverage for SimpleX config, adapter behavior, and integration points. |
README.md |
Mentions SimpleX as a supported messaging destination. |
hermes_cli/tools_config.py |
Adds SimpleX to platform toolset configuration UI. |
hermes_cli/status.py |
Adds SimpleX env var/status reporting. |
hermes_cli/gateway.py |
Adds SimpleX to gateway setup wizard and platform status logic. |
gateway/run.py |
Wires SimpleX adapter creation and authorization env var support. |
gateway/platforms/simplex.py |
New SimpleX WebSocket adapter implementation. |
gateway/config.py |
Adds Platform.SIMPLEX and env override/home-channel config support. |
gateway/channel_directory.py |
Includes SimpleX in session-derived channel directory generation. |
cron/scheduler.py |
Adds SimpleX delivery resolution (including simplex:group:<id> parsing) and platform map. |
agent/prompt_builder.py |
Adds SimpleX-specific response formatting guidance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Correlation tracking for send commands | ||
| self._pending_responses: Dict[str, asyncio.Future] = {} | ||
| self._corr_counter = 0 | ||
|
|
There was a problem hiding this comment.
SimplexAdapter uses self._pending_file_transfers in _handle_event/_handle_chat_item, but this dict is never initialized in __init__. This will raise AttributeError on the first inbound voice/file transfer event. Initialize it (e.g., self._pending_file_transfers: dict[int, dict] = {}) alongside the other connection state in __init__.
| # Track inbound file/voice transfers until fully processed | |
| self._pending_file_transfers: Dict[int, dict] = {} |
| # Platform message length limits (from adapter class attributes) | ||
| from gateway.platforms.simplex import SimplexAdapter as _SimplexAdapter | ||
| _MAX_LENGTHS = { | ||
| Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, | ||
| Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, | ||
| Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, | ||
| Platform.SIMPLEX: _SimplexAdapter.MAX_MESSAGE_LENGTH, |
There was a problem hiding this comment.
_send_to_platform reads _SimplexAdapter.MAX_MESSAGE_LENGTH, but gateway.platforms.simplex.SimplexAdapter does not define a MAX_MESSAGE_LENGTH class attribute (only a module-level MAX_MESSAGE_LENGTH). This will raise AttributeError during every send_message call (even for non-SimpleX targets) when building _MAX_LENGTHS. Add MAX_MESSAGE_LENGTH as a class attribute on SimplexAdapter (to match other adapters) or reference the module constant instead.
| # Platform message length limits (from adapter class attributes) | |
| from gateway.platforms.simplex import SimplexAdapter as _SimplexAdapter | |
| _MAX_LENGTHS = { | |
| Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.SIMPLEX: _SimplexAdapter.MAX_MESSAGE_LENGTH, | |
| # Platform message length limits (from adapter class attributes/module constants) | |
| from gateway.platforms.simplex import MAX_MESSAGE_LENGTH as _SIMPLEX_MAX_MESSAGE_LENGTH | |
| _MAX_LENGTHS = { | |
| Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, | |
| Platform.SIMPLEX: _SIMPLEX_MAX_MESSAGE_LENGTH, |
| async def test_send_simplex_standalone_dm_format(self, monkeypatch): | ||
| """The standalone _send_simplex should use @<id> for DMs.""" | ||
| from tools.send_message_tool import _send_simplex | ||
|
|
||
| mock_ws = AsyncMock() | ||
| mock_ws.send = AsyncMock() | ||
| mock_ws.recv = AsyncMock( | ||
| return_value=json.dumps( | ||
| { | ||
| "corrId": "test", | ||
| "resp": {"type": "newChatItems"}, | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| with patch("websockets.connect") as mock_connect: | ||
| mock_connect.return_value.__aenter__ = AsyncMock(return_value=mock_ws) | ||
| mock_connect.return_value.__aexit__ = AsyncMock(return_value=False) | ||
|
|
||
| result = await _send_simplex( | ||
| {"ws_url": "ws://localhost:5225"}, | ||
| "42", | ||
| "Hello!", | ||
| ) | ||
|
|
||
| assert result.get("success") is True | ||
| sent_payload = json.loads(mock_ws.send.call_args[0][0]) | ||
| assert sent_payload["cmd"] == "@42 Hello!" | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_send_simplex_standalone_group_format(self, monkeypatch): | ||
| """The standalone _send_simplex should use #<id> for groups (not #group:<id>).""" | ||
| from tools.send_message_tool import _send_simplex | ||
|
|
||
| mock_ws = AsyncMock() | ||
| mock_ws.send = AsyncMock() | ||
| mock_ws.recv = AsyncMock( | ||
| return_value=json.dumps( | ||
| { | ||
| "corrId": "test", | ||
| "resp": {"type": "newChatItems"}, | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| with patch("websockets.connect") as mock_connect: | ||
| mock_connect.return_value.__aenter__ = AsyncMock(return_value=mock_ws) | ||
| mock_connect.return_value.__aexit__ = AsyncMock(return_value=False) | ||
|
|
||
| result = await _send_simplex( | ||
| {"ws_url": "ws://localhost:5225"}, | ||
| "group:99", | ||
| "Hello group!", | ||
| ) | ||
|
|
||
| assert result.get("success") is True | ||
| sent_payload = json.loads(mock_ws.send.call_args[0][0]) | ||
| assert sent_payload["cmd"] == "#99 Hello group!" |
There was a problem hiding this comment.
These tests call tools.send_message_tool._send_simplex(...) with only (extra, chat_id, message), but _send_simplex is defined to require (extra, chat_id, message, chunks, media_files). As written, the tests will fail with a TypeError. Update the tests to match the new signature (and mock SimplexAdapter methods rather than asserting on raw websockets.connect payloads), or restore a backwards-compatible _send_simplex signature with optional chunks/media_files args computed internally.
| await adapter._handle_event(raw_event.get("resp", {})) | ||
|
|
||
| # corrId handling is in the outer wrapper, let's test it directly | ||
| # The _handle_event receives resp, but corrId is checked in _ws_listener | ||
| # Actually, looking at the code, corrId IS checked in _handle_event | ||
| # Let me re-read... |
There was a problem hiding this comment.
test_correlated_response_resolves_future is currently incomplete/misleading: it constructs a corrId wrapper but then calls _handle_event with only resp, and contains TODO-style comments without any assertions. This will pass without testing correlation behavior and makes future readers think correlation is covered. Either complete this test (exercise the actual wrapper path that checks corrId) or remove it.
| await adapter._handle_event(raw_event.get("resp", {})) | |
| # corrId handling is in the outer wrapper, let's test it directly | |
| # The _handle_event receives resp, but corrId is checked in _ws_listener | |
| # Actually, looking at the code, corrId IS checked in _handle_event | |
| # Let me re-read... | |
| await adapter._handle_event(raw_event) | |
| assert fut.done() | |
| assert fut.result() == raw_event["resp"] | |
| assert "corr_1" not in adapter._pending_responses |
238d036 to
acf7b4f
Compare
acf7b4f to
7a5ba04
Compare
|
I rebased again. Can we merge this? SimpleX adapter has been very stable and I use it as a daily driver. Might be useful for people who don't want phone numbers (no WhatsApp / Signal), don't want CAPTCHA (no Signal) and want privacy (no Telegram), which most messengers don't provide. |
6af6dbe to
b83143b
Compare
831be32 to
97542d8
Compare
97542d8 to
3ca8808
Compare
f49f8b2 to
0c1af3e
Compare
|
Can we get this merged or something? It's concerning from a privacy and robustness perspective to not have SimpleX as a platform to communicate with the AI agent on. @jooray, if they don't merge it, I'd be happy to use your fork of Hermes. |
Implements a full SimpleX Chat messaging platform adapter following the 16-step ADDING_A_PLATFORM.md checklist. Connects to simplex-chat via its WebSocket API, supporting DMs and group messages, file/image/audio attachments, auto-accept contact requests, and correlated command responses. Includes 60 tests covering event handling, sending, file attachments, session sources, authorization, and standalone message routing.
…age support - Replace /fa with /freceive (correct simplex-chat CLI command) - Use fire-and-forget for /freceive (no corr-id reply ever arrives; the old _send_command approach blocked the event loop for 30s) - Handle rcvFileDescrReady event to accept file transfers that arrive before the newChatItems event (common for XFTP transfers) - Add send_voice() method that sends audio as type:"voice" via the /_send JSON API so recipients see an inline voice note player instead of a generic downloadable file attachment
The previous f-string approach only escaped double quotes but not newlines, backslashes, or other control characters. Multi-line agent responses (e.g. nightly reports) would produce malformed JSON causing simplex-chat to return 'commandError: Failed reading: empty'.
The health monitor was force-reconnecting every ~2.5 minutes even when the connection was healthy. ping/pong keepalives (20s interval) already handle connection liveness. Downgrade to debug log instead of forcing reconnects during normal idle periods.
- cron/scheduler.py: handle simplex "group:<N>" format in _resolve_delivery_target — the embedded colon was causing chat_id="group" instead of "group:<N>" - tools/send_message_tool.py: replace raw websockets _send_simplex with proper SimplexAdapter-based implementation supporting text chunks + voice/image/document media attachments - tools/send_message_tool.py: add simplex to _parse_target_ref — "group:<N>" is a valid explicit ID, not just numerics - tools/send_message_tool.py: add Platform.SIMPLEX to _MAX_LENGTHS (8000 chars) for proper message chunking - docs: update simplex.md with voice message docs, fix health monitoring description
…Socket ready race Two bugs that caused cron job delivery to SimpleX to silently fail: 1. SimplexAdapter was missing MAX_MESSAGE_LENGTH as a class attribute. send_message_tool._send_to_platform() accesses it as SimplexAdapter.MAX_MESSAGE_LENGTH to chunk long messages before sending. The constant existed at module level but was never promoted to the class, causing an AttributeError on every cron delivery attempt. Fix: add MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH to SimplexAdapter. 2. _send_simplex() creates a transient SimplexAdapter and calls connect(), then immediately calls send(). connect() schedules _ws_listener via asyncio.create_task(), but returns before that task has had a chance to run and set self._ws. send() → _send_command() checks self._ws at the top and returns None (with a warning) if it is not yet set, so the message was silently dropped. Fix: after connect() returns, poll adapter._ws with asyncio.sleep(0.1) until it is populated (the _ws_listener task runs on the first yield). Times out after 5 s with an explicit error rather than silently dropping the message.
TTS/audio tools embed MEDIA:<path> markers in their response text to signal file attachments. Without this, the raw path string was sent as a chat message instead of an inline voice note. SimplexAdapter.send() now: - strips MEDIA:<path> tags from the text before sending - skips the text send entirely if nothing remains after stripping - sends each extracted path via send_voice() (.ogg/.mp3/.wav/.m4a/.opus) or send_document() for other file types This fixes cron voice digest delivery without touching any caller code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream PR "fix: resolve listed messaging targets consistently" routed cron delivery target parsing through _parse_target_ref. Agents sometimes save jobs with targets like "simplex:group:1:bGlYMXIxRjNTQ3hqZHIzZg==", where the trailing base64 chunk is a SimpleX invitation-hash that should not be part of the chat_id. Without stripping, the SimpleX adapter emits "/_send NousResearch#1:bGlYMXIxRjNTQ3hqZHIzZg== json [...]" — an invalid group reference — and because the adapter treats unrecognised responses as success, cron jobs silently report ok while no message is delivered. Strip anything after the numeric group ID in _parse_target_ref so the SimpleX CLI always receives a clean "#<N>" reference.
…init__ _handle_event and _handle_chat_item populated and consumed self._pending_file_transfers without it ever being created in __init__, so the first inbound voice/file transfer would raise AttributeError. Add it alongside the other connection state with an explicit type hint.
- test_correlated_response_resolves_future previously passed
``raw_event.get("resp", {})`` into _handle_event, stripping the corrId
it was trying to exercise, and contained TODO-style thinking comments
with no assertions. Pass the full event, assert the pending future
resolves with the resp dict and gets popped from _pending_responses.
- test_send_simplex_standalone_{dm,group}_format called
tools.send_message_tool._send_simplex with only (extra, chat_id,
message), missing the required (chunks, media_files) args — the tests
would have raised TypeError on every run. Rewritten to call
_send_simplex with the full signature and to mock SimplexAdapter
directly (the implementation no longer talks to websockets.connect).
- test_send_group_format asserted the adapter emits "NousResearch#99 Hello group!"
but the adapter now uses the unambiguous structured form
"/_send #<id> json [...]". Update assertion to match and verify the
JSON payload is correctly encoded.
Add text message batching to SimplexAdapter (same approach as Telegram's built-in batching). When a user sends multiple messages in quick succession, they are concatenated into a single event with a 0.8s quiet period before dispatching to handle_message. This prevents message dropping without changing base.py or affecting other platform adapters. Configurable via HERMES_SIMPLEX_TEXT_BATCH_DELAY (default 0.8s).
SimpleX adapter was missing send_image_file() override, falling back to the base class default which just prints the file path as text. Route local file paths through the existing send_image() method instead.
Without this override, the base class falls back to printing the path as text. SimpleX-chat handles video files via the same /f command as documents. send_animation() already works via the base class chain (falls back to send_image).
The /f command interprets #<id> as a group display name, not a numeric group ID. This caused all file sends to groups to silently fail with groupNotFoundByName. Switch send_image and send_document to use the /_send structured API (which already worked for text and voice) so files are addressed by numeric ID consistently.
… display SimpleX clients cannot display WebP images inline. Convert non-PNG/JPEG images to PNG before sending, and generate a 128px JPEG thumbnail for the inline preview. Uses Pillow when available, falls back to ImageMagick.
Text messages are now batched via asyncio.create_task + sleep, so tests that call _handle_event must await the pending flush task before asserting handle_message was awaited. Zero the delay in the fixture and add _flush_text_batches() helper. Also update two image-send assertions for the new `/_send @id json [...]` command shape (replacing the older `/f @id <path>` form).
0c1af3e to
6b96653
Compare
|
Superseded by #27978, which rebuilds this work on top of the SimpleX platform-plugin scaffold ( |
Summary
send_messageintegrationWhy SimpleX belongs in Hermes
Documentation
website/docs/user-guide/messaging/simplex.mdNotes