Skip to content

feat(gateway): add XMPP platform plugin - #30647

Closed
alien2003 wants to merge 2 commits into
NousResearch:mainfrom
alien2003:feat/xmpp-platform-plugin
Closed

alien2003 wants to merge 2 commits into
NousResearch:mainfrom
alien2003:feat/xmpp-platform-plugin

Conversation

@alien2003

@alien2003 alien2003 commented May 22, 2026

Copy link
Copy Markdown

What does this PR do?

Adds XMPP as a bundled platform plugin under plugins/platforms/xmpp/, giving Hermes feature parity with the other 1:1 messaging adapters (SimpleX, IRC, LINE, WhatsApp). The adapter requires zero core edits — slixmpp is imported lazily, Platform("xmpp") is auto-discovered via the bundled-plugin scan in gateway/config.py, and all hooks (setup, env-enablement, cron delivery, standalone send, allowlist) are wired through register_platform() kwargs the way the sibling plugins do it.

XMPP is an open, federated chat protocol. The plugin works against any self-hosted (Prosody, ejabberd, Snikket) or hosted server, supports 1:1 chats and MUC rooms, attaches files natively via XEP-0363 HTTP Upload, renders rich text via XEP-0071 XHTML-IM, and integrates with cron-job delivery and the send_message tool out of the box.

Related Issue

No tracking issue; this is a new platform addition that matches an existing extension pattern.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Changes Made

  • plugins/platforms/xmpp/adapter.pyXMPPAdapter, env-enablement, standalone send, interactive setup, registration. slixmpp imported lazily. HTTP Upload via xep_0363, OOB hints via xep_0066, XHTML-IM via xep_0071.
  • plugins/platforms/xmpp/__init__.py — re-exports register.
  • plugins/platforms/xmpp/plugin.yaml — manifest with requires_env + optional_env entries surfaced by the config UI (including XMPP_UPLOAD_SERVICE and XMPP_HTML_FORMATTING).
  • tests/gateway/test_xmpp_plugin.py — 66 unit tests covering enum discovery, requirements gating, env enablement, init paths, helpers, send routing, inbound dispatch (including own-echo / unaddressed-MUC filtering), XHTML-IM markdown conversion (including URL-scheme sanitization and HTML-escaping inside code spans), single-chunk vs multi-chunk dual-body behavior, HTTP Upload success + fallback paths, pinned-service shortcut, standalone send guards, and register() metadata.
  • website/docs/user-guide/messaging/xmpp.md — full setup guide (Prosody quickstart, env vars, allowlist, HTTP Upload + XHTML-IM behavior, cron delivery, limitations).
  • website/docs/user-guide/messaging/index.md — Next Steps link.
  • website/docs/reference/environment-variables.md — XMPP_* var reference.
  • scripts/release.py — author map entry for contributor email.

Design

User-visible: operator sets XMPP_JID, XMPP_PASSWORD, XMPP_ALLOWED_USERS (and optionally XMPP_ROOMS) in ~/.hermes/.env, runs hermes gateway start. 1:1 chats from allowlisted JIDs reach the agent; MUC messages reach the agent only when prefixed with the bot's nickname (hermes: …), matching IRC's convention. Cron jobs target this platform via deliver=xmpp or send_message(target="xmpp:…"). MUC targets use a muc: prefix in the routing surface.

The agent can emit markdown (rendered via XHTML-IM dual body for opt-in clients, with plain-text fallback for the rest) and MEDIA:/path/to/file (uploaded via XEP-0363 to the server's upload component, delivered with an XEP-0066 OOB extension so clients render the attachment inline). Servers without an upload component fall back to a clear "📎 filename (upload failed: …)" text line.

Alternatives considered:

  1. Hand-roll the XMPP stream on top of asyncio.open_connection + SSL (IRC's approach). XMPP needs XML-stream/SASL/STARTTLS negotiation, stanza dispatch, MUC nick handling, presence and stream resumption — reimplementing that correctly is hundreds of lines of subtle protocol code and we'd own every CVE. Rejected.
  2. aioxmpp — heavier, releases lagging, smaller user base than slixmpp.
  3. slixmpp (chosen). Asyncio-native fork of SleekXMPP, BSD-licensed, used in production (poezio, gajim). Built-in support for XEP-0030, XEP-0045, XEP-0066, XEP-0071, XEP-0085, XEP-0199, XEP-0363 — everything the adapter needs with no third-party glue. Lazy-imported, so the plugin is discoverable even when the package is absent (the same check_requirements() gate SimpleX uses for websockets).

Edge cases covered: missing credentials → adapter never constructed; TLS disabled emits a warning at connect time; MUC self-echo filtered on mucnick; unaddressed room chatter ignored; auth allowlist strips JID resources before lookup; CR/LF/NUL rejected in standalone-send chat_id; profile isolation via acquire_scoped_lock("xmpp", bare_jid) so two profiles cannot share an account; control characters dropped from message bodies so the XML 1.0 serializer never chokes; HTTP Upload service discovery cached so per-send latency is one IQ + one HTTP PUT, with sticky text fallback on UploadServiceNotFound / FileTooBig / HTTPError; XHTML-IM only emitted for single-chunk responses (multi-chunk falls back to plain text to avoid splitting XHTML across stanzas); javascript: / data: / vbscript: URL schemes rejected by the link sanitizer.

Limitations (documented in the setup guide):

  • No end-to-end encryption. OMEMO / OpenPGP are out of scope; use a trusted server and STARTTLS for transport security.
  • No inbound media handling yet. Files attached to incoming messages are not auto-downloaded; this PR focuses on outbound media. A followup can wire cache_image_from_url into the OOB / stanza-extension parsers.

How to Test

# Unit tests (66 cases)
scripts/run_tests.sh tests/gateway/test_xmpp_plugin.py

# Sibling-plugin regression sweep
scripts/run_tests.sh tests/gateway/test_xmpp_plugin.py \
                     tests/gateway/test_simplex_plugin.py \
                     tests/gateway/test_irc_adapter.py \
                     tests/gateway/test_line_plugin.py \
                     tests/gateway/test_platform_registry.py \
                     tests/hermes_cli/test_plugins.py

Manual E2E (against a local Prosody container):

docker run -d --name prosody -p 5222:5222 -e LOCAL=hermes \
  -e DOMAIN=localhost -e PASSWORD=hermes-dev-only prosody/prosody

cat >> ~/.hermes/.env <<'ENV'
XMPP_JID=hermes@localhost
XMPP_PASSWORD=hermes-dev-only
XMPP_HOST=127.0.0.1
XMPP_FORCE_STARTTLS=false
XMPP_ALLOWED_USERS=you@localhost
ENV

pip install slixmpp
hermes gateway start

Then DM the bot from any XMPP client (Dino, Gajim, Conversations) registered as you@localhost. Try sending markdown (**bold**) and ask the agent to share a file with MEDIA:/path/to/file.png.

Backward compatibility

New platform — no public API, on-disk format, or wire-format changes. Platform("xmpp") was previously rejected by _missing_(); existing configurations are unaffected.

Notes for reviewers

  • New dependency: slixmpp (BSD, lazy-imported, no pyproject.toml addition). Matches the SimpleX/websockets pattern — the plugin is discoverable even when the package is absent. Users install it explicitly via the documented pip install slixmpp. The XEP-0363 and XEP-0071 wiring is all done through slixmpp's bundled XEP plugins, so no further deps either.
  • The XMPP_FORCE_STARTTLS switch maps to slixmpp's enable_starttls and enable_direct_tls instance attributes. There is no true "refuse-plaintext" enforcement in slixmpp's public surface; the env name keeps continuity with operator expectations and the falsy path logs a warning. If you'd prefer a different name (XMPP_USE_TLS / XMPP_TLS), happy to rename.
  • The MUC muc: chat-id prefix is plugin-local and only matters for the routing surface (send_message, _resolve_target). It does not leak into the platform wire format.
  • Markdown→XHTML uses the markdown library when available (already pinned for the matrix extra) and falls back to a regex pipeline matching the pattern Matrix uses for org.matrix.custom.html.

Adds XMPP as a bundled platform plugin under `plugins/platforms/xmpp/`,
giving Hermes feature parity with the other 1:1 messaging adapters
(SimpleX, IRC, LINE). The adapter requires zero core edits — slixmpp
is imported lazily, `Platform("xmpp")` is auto-discovered via the
bundled-plugin scan in `gateway/config.py`, and all hooks (setup,
env-enablement, cron delivery, standalone send, allowlist) are wired
through `register_platform()` kwargs.

Plugin contract:
- `check_requirements()` requires XMPP_JID + XMPP_PASSWORD + slixmpp
- `validate_config()` / `is_connected()` accept env or extra input
- `_env_enablement()` seeds PlatformConfig.extra (jid/host/port/tls/
  rooms/nickname + home_channel) so env-only setups show up in
  `hermes gateway status` without instantiating the client
- `_standalone_send()` opens an ephemeral session for cron deliveries
  that run separately from the gateway
- `interactive_setup()` provides a stdin wizard for `hermes setup gateway`
- `register()` wires the adapter into the registry with required_env,
  cron_deliver_env_var, allowed_users_env, install_hint, emoji, and a
  platform_hint for the LLM (plain text, no markdown, MUC by nickname).

Behavior:
- 1:1 chat over `<message type='chat'>`, replies sent the same way.
- MUC rooms join with XMPP_NICKNAME and only reply when addressed
  (matches IRC's convention); MUC targets use a `muc:` prefix in the
  routing surface so `send_message(target="xmpp:muc:room@conf", ...)`
  works.
- Bare JID allowlist via XMPP_ALLOWED_USERS (resources stripped before
  the auth check); XMPP_ALLOW_ALL_USERS=true bypass for dev.
- STARTTLS on by default (XMPP_FORCE_STARTTLS); explicit warning when
  disabled.
- Profile isolation via `acquire_scoped_lock("xmpp", bare_jid)` so two
  profiles cannot log in as the same account.
- Markdown stripped before send, control characters dropped to keep
  the XML 1.0 body valid, long messages chunked under a configurable
  byte ceiling.

Lazy dependency: slixmpp is imported inside `connect()` and inside
the standalone sender; the plugin is importable and discoverable even
when slixmpp is missing — `check_requirements()` returns False until
`pip install slixmpp` is run. No pyproject extras are introduced.

Environment variables:
  XMPP_JID                  Bare JID (required)
  XMPP_PASSWORD             Account password (required)
  XMPP_HOST                 Host override (default: SRV lookup)
  XMPP_PORT                 Port override (default: 5222)
  XMPP_FORCE_STARTTLS       Require TLS negotiation (default: true)
  XMPP_NICKNAME             MUC nickname (default: JID local part)
  XMPP_ROOMS                Comma-separated MUC JIDs to auto-join
  XMPP_ALLOWED_USERS        Allowlisted bare JIDs
  XMPP_ALLOW_ALL_USERS      Dev-only escape hatch
  XMPP_HOME_CHANNEL         Default cron delivery target
  XMPP_HOME_CHANNEL_NAME    Human label for the home channel

Validation:
- `python -m pytest tests/gateway/test_xmpp_plugin.py` → 45 passed
- Combined sibling plugin run
  (test_xmpp_plugin + test_simplex_plugin + test_irc_adapter +
  test_line_plugin + test_platform_registry + test_plugins +
  test_config) → 352 passed
- End-to-end plugin discovery + `Platform("xmpp")` enum lookup +
  `register_platform()` wiring verified against `discover_plugins()`.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins comp/gateway Gateway runner, session dispatch, delivery labels May 22, 2026
@alt-glitch

Copy link
Copy Markdown

Competing XMPP adapter PRs already open: #3105 (XMPP + OMEMO support) and #17469 (XMPP/Jabber adapter using slixmpp). Also addresses feature requests #13049 and #2988. Recommend coordinating with those PRs.

Brings the XMPP plugin closer to feature parity with WhatsApp / Telegram by
adding native attachment delivery and rich-text formatting, both
implemented as slixmpp plugins so no new pyproject dependencies are
introduced.

XEP-0363 HTTP File Upload
- Register ``xep_0363`` + ``xep_0066`` on connect.
- ``_resolve_upload_service`` pre-warms the upload-component JID on
  session_start (or honors a pinned ``XMPP_UPLOAD_SERVICE``); the result
  is cached so per-send discovery doesn't happen.
- Override ``send_image_file``, ``send_document``, ``send_voice``,
  ``send_video``, and ``send_animation`` to route local files through
  ``_upload_then_send`` → ``upload_file`` → OOB stanza
  (``<x xmlns='jabber:x:oob'><url>...</url></x>``). Clients that
  understand XEP-0066 render the attachment inline; clients that don't
  still see the URL in ``<body>``.
- ``UploadServiceNotFound`` / ``FileTooBig`` / ``HTTPError`` are caught
  and converted to a sticky local fallback: a text bubble describing
  the file with the upload-failure reason — the same shape the base
  class default uses.
- ``send_image`` (URL form) attaches an OOB extension for ``https://``
  URLs so inline ``![alt](url)`` images in the agent's response render
  natively when the host is reachable.

XEP-0071 XHTML-IM
- Register ``xep_0071`` on connect (gated by ``XMPP_HTML_FORMATTING``).
- ``_markdown_to_xhtml_im`` converts to a safe XHTML subset
  (``<strong>``, ``<em>``, ``<code>``, ``<pre>``, ``<a>``, ``<ul>``,
  ``<ol>``, ``<li>``, ``<blockquote>``) using the ``markdown`` library
  when available, with a regex fallback otherwise — same dual path the
  Matrix adapter uses for ``org.matrix.custom.html``. URLs are sanitized
  to reject ``javascript:`` / ``data:`` / ``vbscript:`` schemes.
- ``send()`` emits the html block only for single-chunk messages and
  only when the rendered fragment differs from a bare escape of the
  plain body. Chunked sends fall back to plain text to avoid splitting
  XHTML at arbitrary byte boundaries.

New environment variables
  XMPP_UPLOAD_SERVICE       Pin the upload component JID
  XMPP_HTML_FORMATTING      Disable XHTML-IM emission (default true)

Platform hint updated so the agent knows it can emit ``MEDIA:`` tags and
markdown freely; setup wizard, plugin.yaml, and the user-facing doc gain
matching guidance.

Validation:
- ``python -m pytest tests/gateway/test_xmpp_plugin.py`` →
  66 passed (21 new cases covering the HTML converter, dual body,
  upload-success path, upload fallback, pinned-service shortcut, new env
  enablement seeds).
- Sibling-plugin regression sweep (xmpp + simplex + irc + line +
  platform_registry + plugins + config) → 373 passed.
- End-to-end discovery + ``Platform("xmpp")`` enum lookup +
  ``XMPP_UPLOAD_SERVICE`` adapter wiring verified against
  ``discover_plugins()``.
@alien2003

Copy link
Copy Markdown
Author

Followup: addressed the two deferred items (HTTP Upload + XHTML-IM) called out in the original PR notes. The branch now has a second commit 8c9e325.

What's new

  • XEP-0363 HTTP File Upload. send_image_file, send_document, send_voice, send_video, and send_animation upload through the server's HTTP Upload component and deliver the resulting HTTPS URL with an XEP-0066 OOB extension. Service discovery is pre-warmed on session_start and cached, so per-send latency stays at one IQ round-trip + the HTTP PUT. Servers without an upload component fall back to a text bubble describing the file plus the failure reason — same shape as the base class default.
  • XEP-0071 XHTML-IM. Markdown in the agent's reply is sent in two parallel forms: <body> (plain text fallback) and <html> (XHTML-IM subset — <strong>, <em>, <code>, <pre>, <a>, <ul>, <ol>, <li>, <blockquote>). Dual-body only fires on single-chunk messages so we never split XHTML across stanza boundaries.
  • Two new env vars: XMPP_UPLOAD_SERVICE (pin the upload component JID), XMPP_HTML_FORMATTING (opt out of XHTML-IM emission).
  • Platform hint updated so the agent knows it can emit MEDIA:/path tags and use markdown freely.

Notes for review

  • Markdown→XHTML uses the markdown library when available (already pinned for the matrix extra) and falls back to a regex pipeline matching the pattern Matrix uses for org.matrix.custom.html. URL sanitizer rejects javascript: / data: / vbscript: schemes.
  • Upload errors (UploadServiceNotFound, FileTooBig, HTTPError) are caught at the adapter boundary and converted to a sticky text fallback so a misconfigured server can't break message delivery.
  • _upload_service_resolved is a tri-state cache (None → not probed, False → unavailable, str → service JID). The pinned path skips discovery entirely.

Validation

python -m pytest tests/gateway/test_xmpp_plugin.py          # 66 passed (21 new)
python -m pytest tests/gateway/test_xmpp_plugin.py \
                 tests/gateway/test_simplex_plugin.py \
                 tests/gateway/test_irc_adapter.py \
                 tests/gateway/test_line_plugin.py \
                 tests/gateway/test_platform_registry.py \
                 tests/hermes_cli/test_plugins.py \
                 tests/gateway/test_config.py                # 373 passed
ruff check plugins/platforms/xmpp/ tests/gateway/test_xmpp_plugin.py

End-to-end discovery + Platform("xmpp") enum lookup + XMPP_UPLOAD_SERVICE adapter wiring verified against discover_plugins().

No new pyproject dependencies — slixmpp ships these XEPs as bundled plugins.

@alien2003

Copy link
Copy Markdown
Author

Thanks for the heads-up — I missed both prior PRs in my pre-flight search and I appreciate you flagging them. Closing this one in favor of #17469 (which @ericlarslee has had production-tested for almost a month and already has a community APPROVE) is the right call.

Three small things from this branch that I think would be useful as targeted follow-ups on top of #17469 once it lands, rather than as a competing PR:

  1. XEP-0071 XHTML-IM dual body — single-chunk markdown rendered to a safe XHTML subset (<strong>/<em>/<code>/<pre>/<a>/<ul>/<ol>/<li>/<blockquote>) alongside the plain <body> fallback. URL sanitizer rejects javascript:/data:/vbscript:. Single-chunk gate avoids splitting XHTML across stanzas. About 80 lines + tests.
  2. Prosody-in-Docker E2E fixture — module-scoped pytest fixture that spins up Prosody with mod_http_file_share and a pair of test accounts, gated on HERMES_XMPP_E2E=1 so CI stays untouched. Useful regardless of which adapter design ships.
  3. Plugin-form migration — only if you'd want it after feat(gateway): add XMPP/Jabber platform plugin (plugins/platforms/xmpp) #17469 lands, mirroring the Discord adapter migration (refactor(gateway): migrate Discord adapter to bundled plugin (salvage of #24356) #30591). Happy to take that on as a separate refactor PR if you signal interest; otherwise it's not needed.

The branch with all three is preserved on the fork (alien2003/hermes-agent:feat/xmpp-platform-plugin) for anyone — including @ericlarslee — to pull from if useful. I'll hold on posting them as separate PRs until #17469 is merged so reviewers aren't reading the same XEP three times.

@alien2003

Copy link
Copy Markdown
Author

Closing in favor of #17469 (@ericlarslee), which got there first, has the broader 16-point integration, and already has a community APPROVE waiting on a maintainer merge. Per @alt-glitch's coordination ask, deferring rather than competing.

Preserved on the fork for reference / mining: alien2003/hermes-agent:feat/xmpp-platform-plugin. The pieces that would still be useful as targeted follow-ups on top of #17469 once it lands:

Apologies for the duplicate review noise — I missed the existing PRs in my pre-flight search.

@alien2003

Copy link
Copy Markdown
Author

Closing — superseded by #17469. See thread for context.

@alien2003 alien2003 closed this May 22, 2026
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 comp/plugins Plugin system and bundled plugins 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.

2 participants