Rust Matrix E2EE relay with durable delivery and rollout qualification
Find a file
2026-10-15 05:49:14 +02:00
src feat: show the bot as typing until the route answers 2026-10-15 05:45:10 +02:00
tests feat: direct chats need no mention; plain-text display name mentions 2026-10-14 18:14:08 +02:00
.gitignore docs: publish standalone relay source 2026-08-12 22:38:19 +02:00
.gitleaksignore chore: allowlist Matrix public-key fixture 2026-08-12 22:42:56 +02:00
Cargo.lock fix(relay): decode formatted mentions 2026-08-14 21:15:40 +02:00
Cargo.toml fix(relay): decode formatted mentions 2026-08-14 21:15:40 +02:00
LICENSE docs: publish standalone relay source 2026-08-12 22:38:19 +02:00
README.md feat: show the bot as typing until the route answers 2026-10-15 05:45:10 +02:00

matrix-relay-bot

Rust Matrix E2EE relay with two integrations:

Matrix mention, or any message in a direct chat -> one of the room's webhooks (named routes, set by chat command)
Matrix reply to a message sent for a route -> that route's webhook
Authenticated POST /send -> Matrix text or media event

The consuming environment owns deployment, credentials, and runtime state. The bot logs into an existing Matrix account once to create its own device and crypto store. Normal service startup restores that session; it never receives the Matrix password.

Requirements

  • Rust 1.93 or newer
  • Cargo
  • OpenSSL and pkg-config
  • An existing Matrix account joined to the required encrypted rooms

Build and test

cargo build --locked
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings

First initialization

Store the Matrix password in a mode-private file, then run:

matrix-relay-bot init \
  --matrix-homeserver-url https://matrix.example.test \
  --matrix-bot-user-id @relay:example.test \
  --matrix-device-id TESTDEVICE02 \
  --matrix-device-display-name 'n8n Matrix Relay (Rust)' \
  --matrix-storage-dir /var/lib/matrix-relay-bot/rust \
  --matrix-password-file /run/credentials/matrix-relay-bootstrap.service/password

init performs exactly one password login for the fixed device ID, creates the Matrix SDK SQLite crypto store, performs one initial sync, and atomically commits:

/var/lib/matrix-relay-bot/rust/
├── crypto/
├── outbox.sqlite
└── session.json

Running init again accepts complete matching state without another login. Partial state, identity drift, or homeserver drift fails. A failed first attempt leaves the sibling .rust.init directory for diagnosis; remove it only after determining why initialization failed.

Normal service

serve requires no Matrix password, access-token file, declarative device-key pins, or initial cursor:

WEBHOOK_AUTH_TOKEN='matrix-to-n8n-secret' \
matrix-relay-bot serve \
  --matrix-homeserver-url https://matrix.example.test \
  --matrix-bot-user-id @relay:example.test \
  --matrix-device-id TESTDEVICE02 \
  --matrix-storage-dir /var/lib/matrix-relay-bot/rust \
  --matrix-room-allowlist '!room:example.test' \
  --command-user @alice:example.test \
  --bot-send-token-file /run/credentials/matrix-relay-bot.service/bot-send-token \
  --operator-token-file /run/credentials/matrix-relay-bot.service/operator-token \
  --bot-send-bind-address 127.0.0.1 \
  --bot-send-port 8080 \
  --max-download-bytes 52428800

MATRIX_ROOM_ALLOWLIST is optional. When omitted, every joined room is eligible; startup fails if no joined room exists. Matrix SDK encrypts messages in encrypted rooms and preserves normal Matrix behavior in unencrypted rooms. The durable outbox resumes from its stored cursor and does not replay acknowledged events.

HTTP API

Health is unauthenticated:

curl --fail http://127.0.0.1:8080/healthz

Send text to Matrix with the exact BOT_SEND_TOKEN value in the Authorization header:

curl --fail \
  -H 'Authorization: n8n-to-matrix-secret' \
  -H 'Content-Type: application/json' \
  --data '{"roomId":"!room:example.test","message":"hello"}' \
  http://127.0.0.1:8080/send

Successful response:

{"event_id":"$event:example.test"}

For media, replace message with mediaUrl, kind (image, video, audio, or file), and filename. Optional reply fields are replyToEventId, replyToSenderId, and replyToBody.

To have Matrix replies to the sent message come back to a route, add replyRoute (a route name) and optionally context (a JSON object of at most 4 KiB, {} when left out; context without replyRoute is refused):

curl --fail \
  -H 'Authorization: n8n-to-matrix-secret' \
  -H 'Content-Type: application/json' \
  --data '{"roomId":"!room:example.test","message":"PR #31 is open","replyRoute":"fj","context":{"issue":30}}' \
  http://127.0.0.1:8080/send

The bot remembers the sent event's ID with the room, route and context in outbox.sqlite (it survives restarts) and keeps the newest 1000 entries for at most 30 days. The route does not need to exist yet; it is looked up when a reply arrives (see Replies). If the message was sent but the entry could not be stored, the answer is HTTP 500 with the sent event_id: the message is in the room, but replies to it are not routed.

Cross-signing

On startup, after the first sync, the bot creates its cross-signing identity if the account has none yet, so clients can verify it (emoji comparison through the operator API below). The first upload needs no interactive authentication on homeservers that allow it (Synapse with MAS does). Replacing an existing identity is never attempted. If the upload fails, the bot logs a warning and keeps relaying.

Routes and commands

Each room has its own named routes: URLs its mentions are posted to. The route default takes every mention that names no other route. A room without routes does not forward mentions; the bot answers with a notice instead. Routes are set in the room by mentioning the bot:

@relay !route                      list this room's routes
@relay !route set <url>            set the default route (http or https)
@relay !route set <name> <url>     add or change route <name>
@relay !route reset                remove the default route
@relay !route reset <name>         remove route <name>
@relay !name <new name>            change the bot's display name (all rooms)
@relay !help                       list the commands

A route name is 1 to 32 characters of a-z, 0-9 and -, and not one of the command words set, reset, route, name, help. !route set <url> and !route reset without a name act on default, as before named routes; a room's single route from an earlier version becomes its default route at startup.

The bot answers each command as a notice in reply. Commands are not forwarded. A command may follow the mention pill's Display Name: prefix.

Mentions

A message mentions the bot when it carries the bot in m.mentions, links it with a pill (matrix.to or matrix: link), or contains its user ID (@relay:example.test) or @localpart as text. These forms are reliable.

As a convenience for clients that send the name without a pill, the bot's current display name written as @<display name> also counts, and is removed from text like the localpart: @hl-n8n fj help -> fj help. It must stand as a word (at the start or after whitespace, followed by whitespace, : or the end) and match case-sensitively, so @hl-n8nX does not count. The bot uses its display name in that room (its member event), else its global one, read at startup and updated when !name changes it. A name change reaches a room with the next sync that carries the bot's new member event; until then, and for names other members also carry, prefer the user ID, localpart or a pill.

Direct chats

In a direct chat every text message from the other member counts as addressed to the bot, without a mention: commands (!route, !route set fj <url>, !help, !name) work there as written, and other text is dispatched like a mention (first word a route name, else default). A leading mention is still removed from text.

A room is a direct chat when it has exactly two joined members, one of them the bot. The bot reads the joined members from its state store at every sync, after the sync's state is applied, so a direct chat that gains a third member stops being one from that sync on (and becomes one again when it is back to two). The m.direct / is_direct flag is not used.

A direct chat has its own routes, like any room; it does not fall back to the routes of other rooms, so without a route the bot answers with the "no route" notice. Permissions are unchanged: if the other member is not a command user, change commands are ignored and every message goes to default. The bot's own messages never qualify, and notices (m.notice) from the other member still need a mention, so two bots in a direct chat cannot answer each other in a loop. Replies to messages sent for a route work as in other rooms.

Dispatch

For an ordinary mention, the bot looks at the first word of the cleaned text (after an optional Display Name: prefix). If the room has a route with that name, the mention goes there with the rest as text; otherwise the whole text goes to default. With the routes default (an echo) and fj:

@n8n !route set https://n8n.example.test/webhook/echo
@n8n !route set fj https://n8n.example.test/webhook/fj
@n8n fj fix the issue #30   -> fj,      "text": "fix the issue #30", "route": "fj"
@n8n foo bar                -> default, "text": "foo bar",           "route": "default"

Typing

After a webhook accepts a message, the bot shows as typing in that room for up to 30 seconds. The next message the bot sends into the room (normally the answer through /send) ends it at once; without an answer it simply expires. A failed typing notice is logged and never fails the delivery.

Replies

A message sent through /send with replyRoute is remembered (see HTTP API). Replying to it in Matrix (m.relates_to.m.in_reply_to) posts the reply to that route, without a mention. A reply that only falls back to a thread's latest message (is_falling_back) is not a reply. Replies to other events are ordinary messages: forwarded only when they mention the bot. If the remembered route was removed meanwhile, the bot answers with a notice.

n8n -> /send {"message": "PR #31 is open", "replyRoute": "fj", "context": {"issue": 30}}
@alice replies "please add a test"  -> fj, "text": "please add a test",
                                       "inReplyTo": "$event", "context": {"issue": 30}

Permissions

Who may give commands is fixed at startup; changing it needs a restart:

  • --command-user <user ID> (repeatable; MATRIX_COMMAND_USERS, comma separated): only these users.
  • --command-everyone (MATRIX_COMMAND_EVERYONE=true): every room member.

One of the two is required, and they exclude each other. Routes may point to any http(s) URL, so --command-everyone cannot be combined with WEBHOOK_AUTH_TOKEN: anyone could route that token to their own server.

Action Command users Everyone else
!route, !help yes yes
!route set, !route reset, !name yes ignored and logged, no answer
mention to the default route yes yes
mention to a named route (first word) yes no: the whole text goes to default
reply to a message sent for a route yes no: an ordinary message (default if it mentions the bot, else nothing)

Named routes and replies are for routes with side effects (opening issues, running jobs), so only command users reach them. The default route stays open to every member, as before named routes; point it at something harmless, such as an echo.

Routes live in outbox.sqlite and survive restarts. A mention is queued with its route's URL at that moment; a later route change does not redirect it. Upgrading from the single N8N_WEBHOOK_URL drops mentions still queued without a route (logged at startup).

Webhook

A qualifying decrypted mention is posted to its route:

{
  "text": "fix the issue #30",
  "originalContent": "@relay:example.test fj fix the issue #30",
  "roomId": "!room:example.test",
  "eventId": "$event:example.test",
  "senderId": "@alice:example.test",
  "timestamp": 1770000000000,
  "formattedBody": null,
  "route": "fj"
}

text is the cleaned mention text: the whole of it for default, the rest after the route name for a named route. For a reply, text is the reply's own text without the quoted fallback (the leading > lines), and the payload adds the replied-to event and the stored context:

{
  "text": "please add a test",
  "originalContent": "> <@relay:example.test> PR #31 is open\n\nplease add a test",
  "roomId": "!room:example.test",
  "eventId": "$reply:example.test",
  "senderId": "@alice:example.test",
  "timestamp": 1770000000000,
  "formattedBody": "<mx-reply>…</mx-reply>please add a test",
  "route": "fj",
  "inReplyTo": "$event:example.test",
  "context": {"issue": 30}
}

When WEBHOOK_AUTH_TOKEN is set, every webhook request, to any route, uses Authorization: Bearer <token>. Successful delivery is durable. HTTP 429, HTTP 5xx, and transport failures receive bounded retries; other non-success responses are discarded as permanent failures.

License

MIT