- Rust 100%
|
|
||
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| .gitleaksignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| README.md | ||
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