@sharptrick/parley-xmpp
v0.11.0
Published
@sharptrick/parley-xmpp — Parley backend on XMPP MUC rooms, using MAM for exclusive-since catch-up (the server MUST have MAM enabled). Archive id as both cursor and backendMsgId; live push rides the joined-room message stream.
Maintainers
Readme
@sharptrick/parley-xmpp
XMPP MUC backend for Parley. Implements the seam in
packages/bridge-xmpp/src/index.ts; adding it required zero @sharptrick/parley-core changes.
A topic maps to a MUC room. The per-message
XEP-0359 stanza-id — which equals the
XEP-0313 MAM archive id — is a stable,
server-assigned, per-room value used as BOTH backendMsgId (dedup key) and cursor
(order key). Catch-up is a MAM query, so MAM must be enabled on the server (see below).
Mapping
| Seam | XMPP |
|---|---|
| topic | one MUC room <sanitizedTopic>@<muc_service> (default service muc.parley.local); auto-created by post/subscribe, never by fetch_recent |
| join | <presence to='room/nick'> with <history maxstanzas='0'/> — no replay; tracked + ensured before post/fetch/subscribe |
| post | <message type='groupchat'><body/><origin-id id='<uuid>'/></message>; resolves on the MUC's reflection, returning its <stanza-id by='room' id='…'> |
| cursor / backendMsgId | the <stanza-id> / MAM archive id (XEP-0359 / XEP-0313) — identical via live push and via catch-up |
| fetchRecent({since}) | MAM query (urn:xmpp:mam:2) with RSM <after>since</after> (exclusive), paging forward up to limit; no since → the most recent limit, paging backwards with RSM <before> from the archive tail. Either way limit counts messages the seam carries, not archive rows — a tail made of subject changes or retractions does not shrink the window |
| subscribe | every reflected groupchat <message> carrying a room <stanza-id> → handler (incl. own posts), in archive order |
| admission | a stanza with no <body> — a subject change, a correction, a retraction, a chat state — is not a message on either path; an empty body is |
| resolveIdentity | name convention: backendRef is the MUC nick this connection was last admitted under — which is not always the one it asked for, since a nick-locking service rewrites it (status 210) and it is the rewritten name the archive carries. It matches the senderHandle of a post made now, to a room entered under that nick. Once the nick is settled — pinned by nick, taken from the first post, or reverted after a conflict — every handle resolves to it, because one occupant is one sender; before the first post it is the fold post would apply to this handle ([email protected] → alice_corp.com-<hash>). Occupancy is per room, so a room entered before a conflict revert keeps the sender it entered under and backendRef does not describe it (see "One nick per logical identity") |
| sender | the occupant nick (resource of room@svc/nick), which defaults to identity.handle |
| timestamp | the <delay stamp> the SERVER attested — MAM's <forwarded> envelope, or on the live path a <delay from='room'> the room itself added — else now. A <delay> naming any other entity, or naming none at all (XEP-0203's from is a SHOULD, so an omitted one attests nothing and a real MUC does reflect an occupant's un-attributed <delay> verbatim), is an occupant's own and is ignored, so a co-occupant cannot choose it (informational only) |
Archive ids are not lexically comparable, but core never orders by a cursor — the server's RSM
<after> defines "strictly after" and the MAM archive defines order.
No block_ms here. fetchRecent reads the MAM archive and has no wait to spend a caller's
budget on, so it REFUSES one: a parley_fetch_recent carrying a POSITIVE block_ms rejects with
BlockingFetchUnsupportedError naming this plugin, rather than answering with a page an agent
cannot tell from a long-poll that timed out. Retry without it. Live delivery is subscribe, over
the MUC's live groupchat stanzas.
post'sidentityargument (your config'sidentity.handle) becomes the MUC occupant nick. The sender of every archived message is that nick, and it is thesenderHandlecore reports — so unless you pinnickyourself, the firstposttakesidentity.handleas this connection's nick and the bridge keeps one stable identity across restarts. See "Multiple concurrent sessions".
Notes / caveats
- MAM is mandatory, and checked. Without
mod_mam+mod_muc_mam(Prosody) /mod_mam(ejabberd) the room has no archive — and since the archive id is also the cursor and the post-reflection correlator, a MAM-less server makespostunresolvable andsubscribea silent no-op. The plugin therefore probes the room'sdisco#infoforurn:xmpp:mam:2on its first join and fails every seam call with an error naming the modules to enable, rather than timing out. A server that answers disco but does not actually archive is caught on the next round trip: a reflection with no<stanza-id>, or a MAM query answeredservice-unavailable/feature-not-implemented, is reported the same way. - Occupancy is rebuilt however it is lost. A reconnect is only one way this connection stops
being an occupant: a kick (307), a ban (301), an affiliation change (321), a members-only switch
(322), a MUC service shutdown (332), a room destroy, and a component restart that simply forgets
us all end occupancy with the stream still up. The plugin watches for its own
<presence type='unavailable'>(a nick change, status 303, excluded) and for a post bounced as "not an occupant", drops the room from its join cache, and re-joins subscribed rooms — catch-up-only rooms re-join on their next seam call. That re-entry is deferred and backs off (200 ms, doubling to 6.4 s at the last step, plus up to 200 ms of jitter), because the trigger is remote: a room that ends occupancy on every join — a moderation bot, a members-only toggle, a MUC service that is shutting down — would otherwise be re-joined as fast as the connection can send presence. After 6 consecutive losses, each within a minute of the previous, the plugin logs one loud error and stops re-entering that room; live push for the topic stays dead until apostorfetch_recententers it again. - Room lifetime = durability, and occupancy is not durable. A non-persistent MUC room and its whole MAM archive are destroyed the moment the last occupant leaves — and occupancy is presence on one stream, so it ends at every disconnect, not only at shutdown: a network blip, a server restart or an auto-reconnect all empty the room, and no amount of re-joining brings the archive back. This plugin therefore asks for a persistent room in the config submit of the rooms it creates itself, which is what makes history survive a reconnect. Two cases it cannot cover: a room that already exists as non-persistent, and a MUC service that refuses the field (the plugin falls back to a plain "instant room" submit so the room still unlocks, and logs a loud stderr error naming the room and the consequence — the fallback is a degradation, not a success). For those, configure the MUC service to default rooms persistent, or pre-create persistent rooms for your topics. After a reconnect the plugin re-sends the join presence for every room it had entered — subscribed or catch-up-only — so push and post recover without waiting for a timeout.
- A read never creates a room.
postandsubscribejoin, which auto-creates the room and asks for it to be persistent.fetch_recentdoes not: it probes the room'sdisco#infofirst and enters the room only if the server positively answers it.item-not-found— and equally an answer that settles nothing, such asservice-unavailable,remote-server-timeoutor an IQ that never comes back — returns an empty page with the caller's own cursor instead of provisioning anything, and says so once per room on stderr. A probe this bridge could not get an answer to is not permission to create the room it was asking about, so catch-up on that topic stays empty until the probe answers. Without that, apost_topicspattern with a wildcard in it would let an agent mint an unbounded number of persistent rooms and MAM archives on a shared server just by reading topic names that do not exist — and nothing here ever destroys one. Operators sharing a MUC service with other users should also setrestrict_room_creation(Prosody) and pre-create the rooms for their topics. - Carriage returns do not survive a round trip. XMPP bodies are XML character data, and XML 1.0
§2.11 requires the parser to translate a literal CR (and CRLF) to a single LF before any
application sees it —
post('a\rb')reads back asa\nb. The escape that would survive it (
) has to be produced by the serializer, and@xmpp/xmldoes not emit it for text nodes, so this cannot be fixed inside this plugin: pre-encoding the character reference would only get the&escaped in turn. Every other byte round-trips exactly (the shared conformance suite pins newlines, tabs, surrounding spaces, astral emoji and combining sequences), and an ordinary XMPP client shows what an agent posted; an escape layer of our own would break both. Treat CR as normalized to LF, not preserved. post'sinReplyTois ignored. The seam's optional reply parent is dropped: nothing this backend returns carries the relation back, so an XEP-0461<reply/>would be write-only. A reply posts as an ordinary top-level message in the topic's room.- Cold-creation race. When several instances join a brand-new room simultaneously, exactly one
creates it (status 201) and the rest briefly get
item-not-founduntil that creation commits. The creator unlocks the room (XEP-0045 §10.1.2 config submit) and joiners retry the transient condition, so concurrent cold-start is safe. - Content must be XML-legal. XMPP is one long-lived XML document: a codepoint XML 1.0 forbids
(a C0 control other than tab/newline/CR, a lone surrogate, U+FFFE/U+FFFF) is not a rejected
message but the end of the stream, taking occupancy of every room on the connection with it.
Every caller string this plugin serialises —
postcontent, thesincecursor,nick,muc_service,domain,username— is refused up front with an error naming the offending codepoint, rather than put on the wire. (topicandidentityare folded to a legal charset instead, injectively, so they cannot collide. Neither fold truncates: a JID localpart and resource are capped at 1023 bytes, so a topic oridentity.handlewhose folded name would exceed that is refused with an error naming this plugin and the length — folding it shorter is what would make two of them collide.) - One nick per logical identity. The occupant nick is
identity.handle, folded to the JID resource charset. Two sessions with different handles therefore get different senders on a shared account; two with the same handle are the same sender, which is what "same handle" means. If the nick is already taken by someone else in the room, the join is answeredconflictand the plugin logs a loud error and keeps posting under its provisional per-connection nick — the same way whichever seam call hit the conflict first. Rooms it had already entered under the other nick stay entered under it (occupancy is per room), so only the room that hit the conflict changes sender. Pinnickto a free name to resolve it permanently; aconflicton a pinned nick is a misconfiguration and fails the call instead. - One bridge is one sender. A connection is a single MUC occupant, so the occupant nick is taken
from the FIRST
post'sidentity.handleand a laterpostunder a different handle is archived — and read back — under the first one. That collapse is reported once on stderr; run one bridge per handle, or pinnick, if two handles must stay distinct. - A nick the server rewrites is adopted. A nick-locking deployment admits the join under a nick of its own choosing and says so with status 210. The occupant nick is tracked per room from that presence, so this bridge still recognises its own reflections (and reports the nick the room actually shows) rather than stalling every post until its reflection timeout.
Config (backend_config)
backend_config:
service: "xmpp://127.0.0.1:5222" # default; use xmpps:// (or wss://) for anything non-loopback
domain: "parley.local" # default (the user's host)
muc_service: "muc.parley.local" # default (rooms live here)
username: "parley" # default
password: "parleypass" # default — keep secrets in .env, never commit
# nick: optional; defaults to identity.handle (see "Multiple concurrent sessions")
# mam_page: 200 # default; RSM page size for catch-up pagingEvery key is checked before the connection is opened, and an unknown key is a load error naming
the accepted set — a misspelled muc_servce would otherwise leave every room addressed at the
default MUC service and every join bouncing a condition that names nothing.
Use
xmpps://off localhost.@xmpp/client's STARTTLS is opportunistic — it upgrades only a stream whose peer advertises the feature — and SASL PLAIN is always offered, so withxmpp://(orws://) to a non-loopback host an on-path attacker that strips<starttls/>is handedbackend_config.passwordin cleartext. Aservicewith no scheme at all warns the same way:@xmpp/resolveanswers that form by DNS-SRV, and its candidate list ends at a cleartextxmpp://…:5222it falls back to as soon as 5223 refuses. That configuration is not refused (a loopback dev server legitimately runs unencrypted, which is why the Prosody snippet below setsallow_unencrypted_plain_auth), but it warns loudly on stderr at connect.
Multiple concurrent sessions (one backend_config per config file, same server)
A real deployment is several configs — one per Claude Code session plus one for the remote/chat
server — all pointed at the same XMPP server. service/domain/muc_service must be identical
across every one of them; username/password should usually match too, but nick is the one
field that must NOT, if you set it at all:
service/domain/muc_service— the obvious ones.username/password— sharing one XMPP account across sessions is fine, unlike Matrix. The sender is the MUC nick, not the login, and withnickunset each session takes its ownidentity.handleas its nick — so distinct sessions stay distinct senders on one account, and each keeps the same sender across restarts.nick— leave it unset. Set, it must be unique per concurrent session and nothing enforces that for you: MUC's unique-nickname rule is scoped to the bare JID, so two sessions on the same account can occupy one room under one pinned nick from two resources with no error at any point — both join, both post, and every message from both is attributed to that single nick. A loudconflicterror only appears when the two sessions use different accounts. The same silent merge happens if you give two sessions the sameidentity.handle, which is the honest reading of that config: they are one identity.
Runnable multi-config examples (two Code sessions + a remote/chat config, sharing one XMPP account
with per-handle nicks): examples/multi-session/xmpp.
Retention (server-side, not configured by this plugin)
As with Matrix, retention here is a server feature, not something this plugin's account can
turn on itself — catch-up is a MAM query, so MAM's own archive-expiry setting is the retention
knob. Prosody's mod_mam has archive_expires_after (e.g. "1w", "1m", or "never" —
Prosody's own default is "1w", so a Parley deployment that wants longer history must raise this
explicitly); ejabberd's mod_mam has an analogous default_shaping/archive-cleanup config. Set it
on the server if you want a retention window — this plugin has no opinion on it and needs no
changes either way. Once an archived message expires, fetchRecent returns less history, with no
error signaling that anything was pruned — and if the cursor itself has expired out of the
window, the result is server-dependent: RSM (XEP-0059) says a server should answer
item-not-found for an <after> UID it does not hold, but Prosody's mod_mam ignores it and replays
the surviving archive from the beginning. Core's backendMsgId dedup absorbs the replay, but a
resume from a cursor older than the retention window can cost a full re-read of the archive; size
archive_expires_after above your longest expected bridge downtime.
Run an XMPP server (with MAM)
Use a canonical upstream image — not authored here. The server must enable MAM for MUC.
Prosody (official prosody/prosody image) —
enable mam and muc_mam, allow room creation, and (for plaintext dev) allow_unencrypted_plain_auth:
modules_enabled = { "mam" } -- per-user MAM
Component "muc.parley.local" "muc"
modules_enabled = { "muc_mam" } -- MUC archive (required for fetchRecent)
restrict_room_creation = falseejabberd (official ejabberd/ecs image) — enable
mod_mam (it covers MUC archives).
(or the maintainer dev harness: examples/dev-compose/.)
Run it (CLI)
npm install && npm run build
parley-xmpp --config parley.config.yaml
# or: node packages/bridge-xmpp/dist/cli.js --config parley.config.yaml
# or: PARLEY_CONFIG=parley.config.yaml parley-xmpp
parley-xmpp --help # also --version--config (or -c, or --config=<path>) is the only argument. Anything else — a typo, a
--config whose value the shell ate — exits 2 with a usage message on stderr instead of falling
back to the default parley.config.yaml, since that default names a different deployment's
service, muc_service, identity.handle and topic allowlist. --help/--version answer on
stdout and exit 0; both exit before any connection is opened. Once it is serving, stdout is the
JSON-RPC channel and every diagnostic goes to stderr.
Conformance
# bring up a Prosody/ejabberd with MAM + MUC (examples/dev-compose), then:
cd <repo-root> && npx vitest run packages/bridge-xmpp # the shared @sharptrick/parley-conformance suitePARLEY_XMPP_SERVICE / PARLEY_XMPP_DOMAIN / PARLEY_XMPP_MUC / PARLEY_XMPP_USER /
PARLEY_XMPP_PASS override the defaults; the suite skips itself if no server is reachable.
