Skip to content

feat(gateway): add SimpleX Chat adapter - #4870

Closed
jooray wants to merge 18 commits into
NousResearch:mainfrom
jooray:feature/simplex-adapter
Closed

feat(gateway): add SimpleX Chat adapter#4870
jooray wants to merge 18 commits into
NousResearch:mainfrom
jooray:feature/simplex-adapter

Conversation

@jooray

@jooray jooray commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add a full SimpleX Chat gateway adapter with WebSocket event handling, DM and group delivery, voice/file support, cron home-channel delivery, and send_message integration
  • wire SimpleX through gateway/tooling configuration and add end-user docs, including setup, access control, and operational guidance
  • cover the adapter with dedicated gateway tests and document SimpleX as a first-class messaging option in Hermes

Why SimpleX belongs in Hermes

  • SimpleX is unusually well-suited to agent deployments because it does not require user accounts, phone numbers, SIM cards, or CAPTCHA-based onboarding
  • communication is invite-driven, so only explicitly invited contacts can reach the bot; this is a strong default for bot security and sharply reduces spam and unsolicited traffic
  • it is end-to-end encrypted and less dependent on a single operator than account-centric messaging networks, which makes it attractive for privacy-sensitive and self-hosted workflows
  • if a relay or deployment setup changes, operators can often recover by accepting a new invite link instead of reworking gateway identity/config around a fixed phone number or server identity

Documentation

  • add a dedicated SimpleX setup guide at website/docs/user-guide/messaging/simplex.md
  • mention SimpleX in the top-level README and messaging docs so users can discover it alongside the existing gateway platforms

Notes

Copilot AI review requested due to automatic review settings April 3, 2026 21:12

Copilot AI left a comment

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.

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 SimplexAdapter with 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

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

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__.

Suggested change
# Track inbound file/voice transfers until fully processed
self._pending_file_transfers: Dict[int, dict] = {}

Copilot uses AI. Check for mistakes.
Comment on lines 307 to +313
# 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,

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

_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.

Suggested change
# 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,

Copilot uses AI. Check for mistakes.
Comment thread tests/gateway/test_simplex.py Outdated
Comment on lines +776 to +833
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!"

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/gateway/test_simplex.py Outdated
Comment on lines +566 to +571
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...

Copilot AI Apr 3, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@jooray
jooray force-pushed the feature/simplex-adapter branch 4 times, most recently from 238d036 to acf7b4f Compare April 17, 2026 21:34
@jooray
jooray force-pushed the feature/simplex-adapter branch from acf7b4f to 7a5ba04 Compare April 20, 2026 09:13
@jooray

jooray commented Apr 20, 2026

Copy link
Copy Markdown
Contributor Author

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.

@jooray
jooray force-pushed the feature/simplex-adapter branch from 6af6dbe to b83143b Compare April 22, 2026 10:42
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery labels Apr 22, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to #2558 and #4666 (earlier SimpleX adapter PRs) and #2557 (original feature request). This appears to be the latest/most complete attempt.

@jooray
jooray force-pushed the feature/simplex-adapter branch 3 times, most recently from 831be32 to 97542d8 Compare April 30, 2026 07:22
@jooray
jooray force-pushed the feature/simplex-adapter branch from 97542d8 to 3ca8808 Compare May 1, 2026 13:07
@jooray
jooray force-pushed the feature/simplex-adapter branch 2 times, most recently from f49f8b2 to 0c1af3e Compare May 10, 2026 08:03
@elibroftw

elibroftw commented May 16, 2026

Copy link
Copy Markdown

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.

jooray and others added 8 commits May 18, 2026 12:55
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.
Hermes and others added 10 commits May 18, 2026 12:55
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).
@jooray

jooray commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #27978, which rebuilds this work on top of the SimpleX platform-plugin scaffold (plugins/platforms/simplex/) merged in #27???. No core edits needed and the feature set (groups, native attachments, text batching, XFTP file ingestion, auto-accept, MEDIA tag dispatch) is the same. Closing this one in favour of that path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants