Matrix: replace legacy plugin with new implementation

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 21:45:57 -04:00
parent 04d92f6257
commit dc99958e60
273 changed files with 7227 additions and 16102 deletions

View File

@@ -1,391 +0,0 @@
---
summary: "Matrix-js support status, setup, and configuration examples"
read_when:
- Setting up Matrix-js in OpenClaw
- Configuring Matrix E2EE and verification
title: "Matrix-js"
---
# Matrix-js (plugin)
Matrix-js is the current Matrix channel plugin for OpenClaw.
It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE.
For new setups, use Matrix-js.
If you need legacy compatibility with `@vector-im/matrix-bot-sdk`, use [Matrix (legacy)](/channels/matrix).
## Plugin required
Matrix-js is a plugin and is not bundled with core OpenClaw.
Install from npm:
```bash
openclaw plugins install @openclaw/matrix-js
```
Install from a local checkout:
```bash
openclaw plugins install ./extensions/matrix-js
```
See [Plugins](/tools/plugin) for plugin behavior and install rules.
## Setup
1. Install the plugin.
2. Create a Matrix account on your homeserver.
3. Configure `channels["matrix-js"]` with either:
- `homeserver` + `accessToken`, or
- `homeserver` + `userId` + `password`.
4. Restart the gateway.
5. Start a DM with the bot or invite it to a room.
Minimal token-based setup:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
dm: { policy: "pairing" },
},
},
}
```
Password-based setup (token is cached after login):
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "replace-me",
deviceName: "OpenClaw Gateway",
},
},
}
```
Matrix-js stores cached credentials in `~/.openclaw/credentials/matrix/`.
The default account uses `credentials.json`; named accounts use `credentials-<account>.json`.
Environment variable equivalents (used when the config key is not set):
- `MATRIX_HOMESERVER`
- `MATRIX_ACCESS_TOKEN`
- `MATRIX_USER_ID`
- `MATRIX_PASSWORD`
- `MATRIX_DEVICE_ID`
- `MATRIX_DEVICE_NAME`
For non-default accounts, use account-scoped env vars:
- `MATRIX_<ACCOUNT_ID>_HOMESERVER`
- `MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN`
- `MATRIX_<ACCOUNT_ID>_USER_ID`
- `MATRIX_<ACCOUNT_ID>_PASSWORD`
- `MATRIX_<ACCOUNT_ID>_DEVICE_ID`
- `MATRIX_<ACCOUNT_ID>_DEVICE_NAME`
Example for account `ops`:
- `MATRIX_OPS_HOMESERVER`
- `MATRIX_OPS_ACCESS_TOKEN`
## Configuration example
This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: {
policy: "pairing",
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
autoJoin: "allowlist",
autoJoinAllowlist: ["!roomid:example.org"],
threadReplies: "inbound",
replyToMode: "off",
},
},
}
```
## E2EE setup
Enable encryption:
```json5
{
channels: {
"matrix-js": {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: { policy: "pairing" },
},
},
}
```
Check verification status:
```bash
openclaw matrix-js verify status
```
Verbose status (full diagnostics):
```bash
openclaw matrix-js verify status --verbose
```
Bootstrap cross-signing and verification state:
```bash
openclaw matrix-js verify bootstrap
```
Verbose bootstrap diagnostics:
```bash
openclaw matrix-js verify bootstrap --verbose
```
Verify this device with a recovery key:
```bash
openclaw matrix-js verify device "<your-recovery-key>"
```
Verbose device verification details:
```bash
openclaw matrix-js verify device "<your-recovery-key>" --verbose
```
Check room-key backup health:
```bash
openclaw matrix-js verify backup status
```
Verbose backup health diagnostics:
```bash
openclaw matrix-js verify backup status --verbose
```
Restore room keys from server backup:
```bash
openclaw matrix-js verify backup restore
```
Verbose restore diagnostics:
```bash
openclaw matrix-js verify backup restore --verbose
```
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
Use `--json` for full machine-readable output when scripting.
When `encryption: true`, Matrix-js defaults `startupVerification` to `"if-unverified"`.
On startup, if this device is still unverified, Matrix-js will request self-verification in another Matrix client,
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
Failed request attempts retry sooner than successful request creation by default.
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
if you want a shorter or longer retry window.
Encrypted runtime state is stored per account and per access token in
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`),
recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`),
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
when those features are in use.
## Automatic verification notices
Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
That includes:
- verification request notices
- verification ready notices (with explicit "Verify by emoji" guidance)
- verification start and completion notices
- SAS details (emoji and decimal) when available
Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match"
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
## Threads
Matrix-js supports native Matrix threads for both automatic replies and message-tool sends.
- `threadReplies: "off"` keeps replies top-level.
- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread.
- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message.
- Inbound threaded messages include the thread root message as extra agent context.
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
- Runtime thread bindings are supported for Matrix-js. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs.
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
### Thread Binding Config
Matrix-js inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
- `threadBindings.enabled`
- `threadBindings.idleHours`
- `threadBindings.maxAgeHours`
- `threadBindings.spawnSubagentSessions`
- `threadBindings.spawnAcpSessions`
Matrix-js thread-bound spawn flags are opt-in:
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
## Reactions
Matrix-js supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
- Outbound reaction tooling is gated by `channels["matrix-js"].actions.reactions`.
- `react` adds a reaction to a specific Matrix event.
- `reactions` lists the current reaction summary for a specific Matrix event.
- `emoji=""` removes the bot account's own reactions on that event.
- `remove: true` removes only the specified emoji reaction from the bot account.
Ack reactions use the standard OpenClaw resolution order:
- `channels["matrix-js"].accounts.<accountId>.ackReaction`
- `channels["matrix-js"].ackReaction`
- `messages.ackReaction`
- agent identity emoji fallback
Ack reaction scope resolves in this order:
- `channels["matrix-js"].accounts.<accountId>.ackReactionScope`
- `channels["matrix-js"].ackReactionScope`
- `messages.ackReactionScope`
Reaction notification mode resolves in this order:
- `channels["matrix-js"].accounts.<accountId>.reactionNotifications`
- `channels["matrix-js"].reactionNotifications`
- default: `own`
Current behavior:
- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages.
- `reactionNotifications: "off"` disables reaction system events.
- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals.
## DM and room policy example
```json5
{
channels: {
"matrix-js": {
dm: {
policy: "allowlist",
allowFrom: ["@admin:example.org"],
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
},
},
}
```
See [Groups](/channels/groups) for mention-gating and allowlist behavior.
## Multi-account example
```json5
{
channels: {
"matrix-js": {
enabled: true,
dm: { policy: "pairing" },
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "syt_assistant_xxx",
encryption: true,
},
alerts: {
homeserver: "https://matrix.example.org",
accessToken: "syt_alerts_xxx",
dm: {
policy: "allowlist",
allowFrom: ["@ops:example.org"],
},
},
},
},
},
}
```
## Configuration reference
- `enabled`: enable or disable the channel.
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
- `userId`: full Matrix user ID, for example `@bot:example.org`.
- `accessToken`: access token for token-based auth.
- `password`: password for password-based login.
- `deviceId`: explicit Matrix device ID.
- `deviceName`: device display name for password login.
- `initialSyncLimit`: startup sync event limit.
- `encryption`: enable E2EE.
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
- `groupPolicy`: `open`, `allowlist`, or `disabled`.
- `groupAllowFrom`: allowlist of user IDs for room traffic.
- `replyToMode`: `off`, `first`, or `all`.
- `threadReplies`: `off`, `inbound`, or `always`.
- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle.
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
- `textChunkLimit`: outbound message chunk size.
- `chunkMode`: `length` or `newline`.
- `responsePrefix`: optional message prefix for outbound replies.
- `ackReaction`: optional ack reaction override for this channel/account.
- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`).
- `reactionNotifications`: inbound reaction notification mode (`own`, `off`).
- `mediaMaxMb`: outbound media size cap in MB.
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`).
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`.
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
- `groups`: per-room policy map.
- `rooms`: legacy alias for `groups`.
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`).

View File

@@ -1,83 +1,45 @@
---
summary: "Matrix support status, capabilities, and configuration"
summary: "Matrix support status, setup, and configuration examples"
read_when:
- Working on Matrix channel features
- Setting up Matrix in OpenClaw
- Configuring Matrix E2EE and verification
title: "Matrix"
---
# Matrix (plugin)
Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user**
on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
Matrix is the Matrix channel plugin for OpenClaw.
It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE.
## Plugin required
Matrix ships as a plugin and is not bundled with the core install.
Matrix is a plugin and is not bundled with core OpenClaw.
Install via CLI (npm registry):
Install from npm:
```bash
openclaw plugins install @openclaw/matrix
```
Local checkout (when running from a git repo):
Install from a local checkout:
```bash
openclaw plugins install ./extensions/matrix
```
If you choose Matrix during configure/onboarding and a git checkout is detected,
OpenClaw will offer the local install path automatically.
Details: [Plugins](/tools/plugin)
See [Plugins](/tools/plugin) for plugin behavior and install rules.
## Setup
1. Install the Matrix plugin:
- From npm: `openclaw plugins install @openclaw/matrix`
- From a local checkout: `openclaw plugins install ./extensions/matrix`
2. Create a Matrix account on a homeserver:
- Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/)
- Or host it yourself.
3. Get an access token for the bot account:
- Use the Matrix login API with `curl` at your home server:
1. Install the plugin.
2. Create a Matrix account on your homeserver.
3. Configure `channels.matrix` with either:
- `homeserver` + `accessToken`, or
- `homeserver` + `userId` + `password`.
4. Restart the gateway.
5. Start a DM with the bot or invite it to a room.
```bash
curl --request POST \
--url https://matrix.example.org/_matrix/client/v3/login \
--header 'Content-Type: application/json' \
--data '{
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": "your-user-name"
},
"password": "your-password"
}'
```
- Replace `matrix.example.org` with your homeserver URL.
- Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same
login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`,
and reuses it on next start.
4. Configure credentials:
- Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`)
- Or config: `channels.matrix.*`
- If both are set, config takes precedence.
- With access token: user ID is fetched automatically via `/whoami`.
- When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`).
5. Restart the gateway (or finish onboarding).
6. Start a DM with the bot or invite it to a room from any Matrix client
(Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE,
so set `channels.matrix.encryption: true` and verify the device.
Minimal config (access token, user ID auto-fetched):
Minimal token-based setup:
```json5
{
@@ -85,14 +47,14 @@ Minimal config (access token, user ID auto-fetched):
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
accessToken: "syt_xxx",
dm: { policy: "pairing" },
},
},
}
```
E2EE config (end to end encryption enabled):
Password-based setup (token is cached after login):
```json5
{
@@ -100,7 +62,85 @@ E2EE config (end to end encryption enabled):
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_***",
userId: "@bot:example.org",
password: "replace-me",
deviceName: "OpenClaw Gateway",
},
},
}
```
Matrix stores cached credentials in `~/.openclaw/credentials/matrix/`.
The default account uses `credentials.json`; named accounts use `credentials-<account>.json`.
Environment variable equivalents (used when the config key is not set):
- `MATRIX_HOMESERVER`
- `MATRIX_ACCESS_TOKEN`
- `MATRIX_USER_ID`
- `MATRIX_PASSWORD`
- `MATRIX_DEVICE_ID`
- `MATRIX_DEVICE_NAME`
For non-default accounts, use account-scoped env vars:
- `MATRIX_<ACCOUNT_ID>_HOMESERVER`
- `MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN`
- `MATRIX_<ACCOUNT_ID>_USER_ID`
- `MATRIX_<ACCOUNT_ID>_PASSWORD`
- `MATRIX_<ACCOUNT_ID>_DEVICE_ID`
- `MATRIX_<ACCOUNT_ID>_DEVICE_NAME`
Example for account `ops`:
- `MATRIX_OPS_HOMESERVER`
- `MATRIX_OPS_ACCESS_TOKEN`
## Configuration example
This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled:
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: {
policy: "pairing",
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
autoJoin: "allowlist",
autoJoinAllowlist: ["!roomid:example.org"],
threadReplies: "inbound",
replyToMode: "off",
},
},
}
```
## E2EE setup
Enable encryption:
```json5
{
channels: {
matrix: {
enabled: true,
homeserver: "https://matrix.example.org",
accessToken: "syt_xxx",
encryption: true,
dm: { policy: "pairing" },
},
@@ -108,41 +148,193 @@ E2EE config (end to end encryption enabled):
}
```
## Encryption (E2EE)
Check verification status:
End-to-end encryption is **supported** via the Rust crypto SDK.
```bash
openclaw matrix verify status
```
Enable with `channels.matrix.encryption: true`:
Verbose status (full diagnostics):
- If the crypto module loads, encrypted rooms are decrypted automatically.
- Outbound media is encrypted when sending to encrypted rooms.
- On first connection, OpenClaw requests device verification from your other sessions.
- Verify the device in another Matrix client (Element, etc.) to enable key sharing.
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
OpenClaw logs a warning.
- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`),
allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run
`pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with
`node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`.
```bash
openclaw matrix verify status --verbose
```
Crypto state is stored per account + access token in
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
If the access token (device) changes, a new store is created and the bot must be
re-verified for encrypted rooms.
Bootstrap cross-signing and verification state:
**Device verification:**
When E2EE is enabled, the bot will request verification from your other sessions on startup.
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
```bash
openclaw matrix verify bootstrap
```
## Multi-account
Verbose bootstrap diagnostics:
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
```bash
openclaw matrix verify bootstrap --verbose
```
Each account runs as a separate Matrix user on any homeserver. Per-account config
inherits from the top-level `channels.matrix` settings and can override any option
(DM policy, groups, encryption, etc.).
Verify this device with a recovery key:
```bash
openclaw matrix verify device "<your-recovery-key>"
```
Verbose device verification details:
```bash
openclaw matrix verify device "<your-recovery-key>" --verbose
```
Check room-key backup health:
```bash
openclaw matrix verify backup status
```
Verbose backup health diagnostics:
```bash
openclaw matrix verify backup status --verbose
```
Restore room keys from server backup:
```bash
openclaw matrix verify backup restore
```
Verbose restore diagnostics:
```bash
openclaw matrix verify backup restore --verbose
```
All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`.
Use `--json` for full machine-readable output when scripting.
When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`.
On startup, if this device is still unverified, Matrix will request self-verification in another Matrix client,
skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts.
Failed request attempts retry sooner than successful request creation by default.
Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours`
if you want a shorter or longer retry window.
Upgrading from the previous public Matrix plugin:
- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible.
- If the previous plugin stored a Matrix room-key backup decryption key locally, startup or `openclaw doctor --fix` will import it into the new recovery-key flow automatically.
- On the next gateway start, backed-up room keys are restored automatically into the new crypto store.
- If the old plugin had local-only room keys that were never backed up, OpenClaw will warn clearly. Those keys cannot be exported automatically from the previous rust crypto store, so some old encrypted history may remain unavailable until recovered manually.
- See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages.
Encrypted runtime state is stored per account and per access token in
`~/.openclaw/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/`.
That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`),
recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`),
thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`)
when those features are in use.
## Automatic verification notices
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
That includes:
- verification request notices
- verification ready notices (with explicit "Verify by emoji" guidance)
- verification start and completion notices
- SAS details (emoji and decimal) when available
Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match"
in their Matrix client, verification completes without requiring a manual OpenClaw tool step.
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
## Threads
Matrix supports native Matrix threads for both automatic replies and message-tool sends.
- `threadReplies: "off"` keeps replies top-level.
- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread.
- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message.
- Inbound threaded messages include the thread root message as extra agent context.
- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided.
- Runtime thread bindings are supported for Matrix. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs.
- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`.
- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead.
### Thread Binding Config
Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides:
- `threadBindings.enabled`
- `threadBindings.idleHours`
- `threadBindings.maxAgeHours`
- `threadBindings.spawnSubagentSessions`
- `threadBindings.spawnAcpSessions`
Matrix thread-bound spawn flags are opt-in:
- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads.
- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads.
## Reactions
Matrix supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions.
- Outbound reaction tooling is gated by `channels["matrix"].actions.reactions`.
- `react` adds a reaction to a specific Matrix event.
- `reactions` lists the current reaction summary for a specific Matrix event.
- `emoji=""` removes the bot account's own reactions on that event.
- `remove: true` removes only the specified emoji reaction from the bot account.
Ack reactions use the standard OpenClaw resolution order:
- `channels["matrix"].accounts.<accountId>.ackReaction`
- `channels["matrix"].ackReaction`
- `messages.ackReaction`
- agent identity emoji fallback
Ack reaction scope resolves in this order:
- `channels["matrix"].accounts.<accountId>.ackReactionScope`
- `channels["matrix"].ackReactionScope`
- `messages.ackReactionScope`
Reaction notification mode resolves in this order:
- `channels["matrix"].accounts.<accountId>.reactionNotifications`
- `channels["matrix"].reactionNotifications`
- default: `own`
Current behavior:
- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages.
- `reactionNotifications: "off"` disables reaction system events.
- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals.
## DM and room policy example
```json5
{
channels: {
matrix: {
dm: {
policy: "allowlist",
allowFrom: ["@admin:example.org"],
},
groupPolicy: "allowlist",
groupAllowFrom: ["@admin:example.org"],
groups: {
"!roomid:example.org": {
requireMention: true,
},
},
},
},
}
```
See [Groups](/channels/groups) for mention-gating and allowlist behavior.
## Multi-account example
```json5
{
@@ -152,16 +344,17 @@ inherits from the top-level `channels.matrix` settings and can override any opti
dm: { policy: "pairing" },
accounts: {
assistant: {
name: "Main assistant",
homeserver: "https://matrix.example.org",
accessToken: "syt_assistant_***",
accessToken: "syt_assistant_xxx",
encryption: true,
},
alerts: {
name: "Alerts bot",
homeserver: "https://matrix.example.org",
accessToken: "syt_alerts_***",
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
accessToken: "syt_alerts_xxx",
dm: {
policy: "allowlist",
allowFrom: ["@ops:example.org"],
},
},
},
},
@@ -169,135 +362,35 @@ inherits from the top-level `channels.matrix` settings and can override any opti
}
```
Notes:
## Configuration reference
- Account startup is serialized to avoid race conditions with concurrent module imports.
- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
- Use `bindings[].match.accountId` to route each account to a different agent.
- Crypto state is stored per account + access token (separate key stores per account).
## Routing model
- Replies always go back to Matrix.
- DMs share the agent's main session; rooms map to group sessions.
## Access control (DMs)
- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code.
- Approve via:
- `openclaw pairing list matrix`
- `openclaw pairing approve matrix <CODE>`
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match.
- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs.
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set).
- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match):
```json5
{
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"!roomId:example.org": { allow: true },
"#alias:example.org": { allow: true },
},
groupAllowFrom: ["@owner:example.org"],
},
},
}
```
- `requireMention: false` enables auto-reply in that room.
- `groups."*"` can set defaults for mention gating across rooms.
- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs).
- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs).
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match.
- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching.
- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`.
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
- Legacy key: `channels.matrix.rooms` (same shape as `groups`).
## Threads
- Reply threading is supported.
- `channels.matrix.threadReplies` controls whether replies stay in threads:
- `off`, `inbound` (default), `always`
- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread:
- `off` (default), `first`, `all`
## Capabilities
| Feature | Status |
| --------------- | ------------------------------------------------------------------------------------- |
| Direct messages | ✅ Supported |
| Rooms | ✅ Supported |
| Threads | ✅ Supported |
| Media | ✅ Supported |
| E2EE | ✅ Supported (crypto module required) |
| Reactions | ✅ Supported (send/read via tools) |
| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) |
| Location | ✅ Supported (geo URI; altitude ignored) |
| Native commands | ✅ Supported |
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list matrix
```
Common failures:
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
- Encrypted rooms fail: crypto support or encryption settings mismatch.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.matrix.enabled`: enable/disable channel startup.
- `channels.matrix.homeserver`: homeserver URL.
- `channels.matrix.userId`: Matrix user ID (optional with access token).
- `channels.matrix.accessToken`: access token.
- `channels.matrix.password`: password for login (token stored).
- `channels.matrix.deviceName`: device display name.
- `channels.matrix.encryption`: enable E2EE (default: false).
- `channels.matrix.initialSyncLimit`: initial sync limit.
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs).
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.groups`: group allowlist + per-room settings map.
- `channels.matrix.rooms`: legacy group allowlist/config.
- `channels.matrix.replyToMode`: reply-to mode for threads/tags.
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
- `enabled`: enable or disable the channel.
- `homeserver`: homeserver URL, for example `https://matrix.example.org`.
- `userId`: full Matrix user ID, for example `@bot:example.org`.
- `accessToken`: access token for token-based auth.
- `password`: password for password-based login.
- `deviceId`: explicit Matrix device ID.
- `deviceName`: device display name for password login.
- `initialSyncLimit`: startup sync event limit.
- `encryption`: enable E2EE.
- `allowlistOnly`: force allowlist-only behavior for DMs and rooms.
- `groupPolicy`: `open`, `allowlist`, or `disabled`.
- `groupAllowFrom`: allowlist of user IDs for room traffic.
- `replyToMode`: `off`, `first`, or `all`.
- `threadReplies`: `off`, `inbound`, or `always`.
- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle.
- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`).
- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests.
- `textChunkLimit`: outbound message chunk size.
- `chunkMode`: `length` or `newline`.
- `responsePrefix`: optional message prefix for outbound replies.
- `ackReaction`: optional ack reaction override for this channel/account.
- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`).
- `reactionNotifications`: inbound reaction notification mode (`own`, `off`).
- `mediaMaxMb`: outbound media size cap in MB.
- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`).
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`.
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
- `groups`: per-room policy map.
- `rooms`: legacy alias for `groups`.
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`).

View File

@@ -870,7 +870,12 @@
},
{
"group": "Maintenance",
"pages": ["install/updating", "install/migrating", "install/uninstall"]
"pages": [
"install/updating",
"install/migrating",
"install/migrating-matrix",
"install/uninstall"
]
},
{
"group": "Hosting and deployment",

View File

@@ -1,7 +1,7 @@
---
summary: "Replace the legacy Matrix plugin with the new Matrix implementation while preserving the public matrix surface and providing automatic migration for current users."
owner: "gumadeiras"
status: "in progress"
status: "implemented"
last_updated: "2026-03-08"
title: "Matrix Supersession Migration"
---
@@ -28,17 +28,19 @@ This plan is also the working implementation tracker. Update the checklist statu
### Current status
- [x] Migration plan written and tracked in repo
- [ ] Replace `extensions/matrix` with the new implementation
- [ ] Remove shipped `matrix-js` public/runtime/package surfaces
- [ ] Preserve legacy `matrix` config compatibility
- [ ] Preserve legacy `matrix` state compatibility
- [ ] Add startup and doctor migration/repair UX
- [ ] Rewrite docs/help/tests to use `matrix` only
- [ ] Verify update flow end to end
- [x] Replace `extensions/matrix` with the new implementation
- [x] Remove shipped `matrix-js` public/runtime/package surfaces
- [x] Preserve legacy `matrix` config compatibility
- [x] Preserve legacy `matrix` state compatibility
- [x] Add startup and doctor migration/repair UX
- [x] Rewrite docs/help/tests to use `matrix` only
- [x] Verify update flow end to end
### Change log
- 2026-03-08: Initial supersession plan written and added to docs as a live checklist.
- 2026-03-08: Replaced `extensions/matrix` with the new Matrix implementation, removed shipped `matrix-js` surfaces, added startup/doctor Matrix migration UX, and deleted `extensions/matrix-js`.
- 2026-03-08: Added encrypted-state migration prep for legacy Matrix rust crypto stores, automatic backup-key extraction, startup room-key restore, and explicit warnings for local-only keys that cannot be exported automatically.
## Summary
@@ -50,153 +52,156 @@ This plan is also the working implementation tracker. Update the checklist statu
## Public surface after cutover
- [ ] Canonical package/install surface stays `@openclaw/matrix` and `openclaw plugins install @openclaw/matrix`.
- [ ] Canonical channel/plugin/binding id is `matrix`.
- [ ] Canonical config namespace is `channels.matrix`.
- [ ] Canonical CLI surface is `openclaw matrix ...`, including the verification/account commands currently only exposed under Matrix-js.
- [ ] Canonical gateway methods become `matrix.verify.status`, `matrix.verify.bootstrap`, and `matrix.verify.recoveryKey`.
- [ ] Canonical ACP/subagent binding channel is `matrix`.
- [ ] Canonical plugin SDK subpath is `openclaw/plugin-sdk/matrix`.
- [ ] Remove all shipped/public `matrix-js` references from docs, config help, tests, install catalog metadata, and package exports.
- [x] Canonical package/install surface stays `@openclaw/matrix` and `openclaw plugins install @openclaw/matrix`.
- [x] Canonical channel/plugin/binding id is `matrix`.
- [x] Canonical config namespace is `channels.matrix`.
- [x] Canonical CLI surface is `openclaw matrix ...`, including the verification/account commands currently only exposed under Matrix-js.
- [x] Canonical gateway methods become `matrix.verify.status`, `matrix.verify.bootstrap`, and `matrix.verify.recoveryKey`.
- [x] Canonical ACP/subagent binding channel is `matrix`.
- [x] Canonical plugin SDK subpath is `openclaw/plugin-sdk/matrix`.
- [x] Remove all shipped/public `matrix-js` references from docs, config help, tests, install catalog metadata, and package exports.
## Migration flow and UX
### Standard npm users
- [ ] No config key change required.
- [ ] No plugin install record rewrite required because the package remains `@openclaw/matrix`.
- [ ] Updating OpenClaw or running `openclaw plugins update` replaces the plugin in place.
- [ ] Startup and doctor automatically repair any legacy Matrix config/state that the new implementation cannot consume directly.
- [x] No config key change required.
- [x] No plugin install record rewrite required because the package remains `@openclaw/matrix`.
- [x] Updating OpenClaw or running `openclaw plugins update` replaces the plugin in place.
- [x] Startup and doctor automatically repair any legacy Matrix config/state that the new implementation cannot consume directly.
### Startup behavior
- [ ] Keep the existing startup auto-migration model.
- [ ] On first startup after upgrade, detect legacy Matrix config/state mismatches and repair them automatically when the repair is deterministic and local.
- [ ] Log a concise one-time summary of what was migrated and only show next steps when user action is still required.
- [x] Keep the existing startup auto-migration model.
- [x] On first startup after upgrade, detect legacy Matrix config/state mismatches and repair them automatically when the repair is deterministic and local.
- [x] Log a concise one-time summary of what was migrated and only show next steps when user action is still required.
### Doctor and update behavior
- [ ] `openclaw doctor --fix` and update-triggered doctor run the same Matrix migration logic, but with richer user-facing output.
- [ ] Doctor shows exactly which Matrix paths/keys were changed and why.
- [ ] Doctor validates the installed Matrix plugin source and surfaces manual repair steps for custom path installs.
- [x] `openclaw doctor --fix` and update-triggered doctor run the same Matrix migration logic, but with richer user-facing output.
- [x] Doctor shows exactly which Matrix paths/keys were changed and why.
- [x] Doctor validates the installed Matrix plugin source and surfaces manual repair steps for custom path installs.
### Custom or local-path installs
- [ ] Do not auto-rewrite arbitrary custom plugin paths.
- [ ] If the legacy Matrix plugin was installed from a custom path and that path is now stale or missing, warn clearly and print the exact replacement command or path to use.
- [ ] If the custom path is valid and already points at the replacement plugin, leave it alone.
- [x] Do not auto-rewrite arbitrary custom plugin paths.
- [x] If the legacy Matrix plugin was installed from a custom path and that path is now stale or missing, warn clearly and print the exact replacement command or path to use.
- [x] If the custom path is valid and already points at the replacement plugin, leave it alone.
### Unsupported scope
- [ ] No backward compatibility for internal `matrix-js` adopters.
- [ ] Do not auto-migrate `channels.matrix-js`, `@openclaw/matrix-js`, `openclaw matrix-js`, or `plugins.entries["matrix-js"]`.
- [x] No backward compatibility for internal `matrix-js` adopters.
- [x] Do not auto-migrate `channels.matrix-js`, `@openclaw/matrix-js`, `openclaw matrix-js`, or `plugins.entries["matrix-js"]`.
## Implementation changes
### 1. Replace identity at the package and plugin layer
- [ ] Overwrite `extensions/matrix` with the Matrix-js implementation instead of renaming user-facing config.
- [ ] Delete `extensions/matrix-js` after the port is complete.
- [ ] Update `extensions/matrix/package.json`, `extensions/matrix/openclaw.plugin.json`, and `extensions/matrix/index.ts` so the package remains `@openclaw/matrix` but exposes the new feature set.
- [ ] Port the Matrix-js CLI and gateway-method registration into `extensions/matrix/index.ts` and register it under `matrix`, not `matrix-js`.
- [ ] Replace all internal `openclaw/plugin-sdk/matrix-js` imports with `openclaw/plugin-sdk/matrix`.
- [ ] Replace the plugin SDK implementation behind `src/plugin-sdk/matrix.ts` with the Matrix-js helper surface superset, then remove the `matrix-js` plugin-sdk export from `package.json`, `scripts/check-plugin-sdk-exports.mjs`, `scripts/write-plugin-sdk-entry-dts.ts`, and related release/build checks.
- [x] Overwrite `extensions/matrix` with the Matrix-js implementation instead of renaming user-facing config.
- [x] Delete `extensions/matrix-js` after the port is complete.
- [x] Update `extensions/matrix/package.json`, `extensions/matrix/openclaw.plugin.json`, and `extensions/matrix/index.ts` so the package remains `@openclaw/matrix` but exposes the new feature set.
- [x] Port the Matrix-js CLI and gateway-method registration into `extensions/matrix/index.ts` and register it under `matrix`, not `matrix-js`.
- [x] Replace all internal `openclaw/plugin-sdk/matrix-js` imports with `openclaw/plugin-sdk/matrix`.
- [x] Replace the plugin SDK implementation behind `src/plugin-sdk/matrix.ts` with the Matrix-js helper surface superset, then remove the `matrix-js` plugin-sdk export from `package.json`, `scripts/check-plugin-sdk-exports.mjs`, `scripts/write-plugin-sdk-entry-dts.ts`, and related release/build checks.
### 2. Preserve legacy `matrix` config compatibility
- [ ] Make the new `matrix` plugin accept the current public legacy `channels.matrix` schema as-is.
- [ ] Keep support for top-level single-account `channels.matrix.*`.
- [ ] Keep support for `channels.matrix.accounts.*`.
- [ ] Keep support for `channels.matrix.defaultAccount`.
- [ ] Keep support for the legacy `rooms` alias.
- [ ] Keep support for existing DM and group policy keys.
- [ ] Keep support for existing bindings that use `match.channel: "matrix"`.
- [ ] Preserve SecretRef password inputs used by the legacy plugin.
- [ ] Do not require rewriting normal single-account configs into `accounts.default`.
- [ ] Add or keep doctor and startup migrations only for keys that are genuinely obsolete or ignored by the new implementation.
- [ ] Ensure config help, schema labels, and reference docs all describe `channels.matrix`, never `channels.matrix-js`.
- [x] Make the new `matrix` plugin accept the current public legacy `channels.matrix` schema as-is.
- [x] Keep support for top-level single-account `channels.matrix.*`.
- [x] Keep support for `channels.matrix.accounts.*`.
- [x] Keep support for `channels.matrix.defaultAccount`.
- [x] Keep support for the legacy `rooms` alias.
- [x] Keep support for existing DM and group policy keys.
- [x] Keep support for existing bindings that use `match.channel: "matrix"`.
- [x] Preserve SecretRef password inputs used by the legacy plugin.
- [x] Do not require rewriting normal single-account configs into `accounts.default`.
- [x] Add or keep doctor and startup migrations only for keys that are genuinely obsolete or ignored by the new implementation.
- [x] Ensure config help, schema labels, and reference docs all describe `channels.matrix`, never `channels.matrix-js`.
### 3. Preserve legacy `matrix` state and runtime behavior
- [ ] Keep `credentials/matrix/credentials*.json` as the credential root.
- [ ] Keep `matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/...` as the canonical runtime and crypto root.
- [ ] Add explicit migration support in the new plugin for direct upgrades from the oldest legacy flat store:
- [ ] `~/.openclaw/matrix/bot-storage.json`
- [ ] `~/.openclaw/matrix/crypto/`
- [ ] Do not retain `matrix-js`-path migration logic in the shipped plugin.
- [ ] Preserve multi-account isolation and default-account behavior exactly on the `matrix` channel.
- [ ] Preserve legacy secrets integration by continuing to use the existing `channels.matrix.*` secret collectors and credential surface definitions.
- [ ] Keep route/session binding, ACP binding, thread binding, and outbound message routing keyed to `matrix`, with the current new Matrix functionality carried over.
- [x] Keep `credentials/matrix/credentials*.json` as the credential root.
- [x] Keep `matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/...` as the canonical runtime and crypto root.
- [x] Add explicit migration support in the new plugin for direct upgrades from the oldest legacy flat store:
- [x] `~/.openclaw/matrix/bot-storage.json`
- [x] `~/.openclaw/matrix/crypto/`
- [x] Do not retain `matrix-js`-path migration logic in the shipped plugin.
- [x] Preserve multi-account isolation and default-account behavior exactly on the `matrix` channel.
- [x] Preserve legacy secrets integration by continuing to use the existing `channels.matrix.*` secret collectors and credential surface definitions.
- [x] Keep route/session binding, ACP binding, thread binding, and outbound message routing keyed to `matrix`, with the current new Matrix functionality carried over.
### 4. Migrate internal Matrix-js-only surfaces to `matrix`
- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across ACP binding schemas and runtime.
- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across thread binding policy and commands.
- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across auto-reply and session context surfaces.
- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across agent binding commands and tests.
- [ ] Replace all CLI help, onboarding text, runtime warnings, and verification prompts from `matrix-js` to `matrix`.
- [ ] Rewrite `docs/channels/matrix.md` to describe the new implementation and new verification, ACP, and thread features.
- [ ] Remove `docs/channels/matrix-js.md`.
- [ ] Update shared docs that still reference Matrix-js, including `docs/tools/acp-agents.md`, `docs/tools/subagents.md`, `docs/tools/plugin.md`, and `docs/gateway/configuration-reference.md`.
- [ ] Leave `docs/zh-CN/**` untouched in this pass.
- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across ACP binding schemas and runtime.
- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across thread binding policy and commands.
- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across auto-reply and session context surfaces.
- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across agent binding commands and tests.
- [x] Replace all CLI help, onboarding text, runtime warnings, and verification prompts from `matrix-js` to `matrix`.
- [x] Rewrite `docs/channels/matrix.md` to describe the new implementation and new verification, ACP, and thread features.
- [x] Remove `docs/channels/matrix-js.md`.
- [x] Update shared docs that still reference Matrix-js, including `docs/tools/acp-agents.md`, `docs/tools/subagents.md`, `docs/tools/plugin.md`, and `docs/gateway/configuration-reference.md`.
- [x] Leave `docs/zh-CN/**` untouched in this pass.
### 5. Automatic messaging and failure handling
- [ ] When startup or doctor rewrites Matrix config, emit a short summary such as:
- [ ] Matrix plugin upgraded in place
- [ ] migrated deprecated Matrix config keys
- [ ] migrated legacy Matrix crypto store
- [ ] no user action required
- [ ] When automatic repair is not safe, emit exact commands, not generic warnings.
- [ ] For custom or stale local plugin paths, point users to the concrete replacement command or path.
- [ ] Never log secrets or token values in migration output.
- [ ] If a legacy state migration fails, continue with clear non-fatal messaging and tell the user what functionality may be degraded until they re-verify.
- [x] When startup or doctor rewrites Matrix config, emit a short summary such as:
- [x] Matrix plugin upgraded in place
- [x] migrated deprecated Matrix config keys
- [x] migrated legacy Matrix crypto store
- [x] no user action required
- [x] When automatic repair is not safe, emit exact commands, not generic warnings.
- [x] For custom or stale local plugin paths, point users to the concrete replacement command or path.
- [x] Never log secrets or token values in migration output.
- [x] If a legacy state migration fails, continue with clear non-fatal messaging and tell the user what functionality may be degraded until they re-verify.
## Test plan and acceptance criteria
### Config compatibility
- [ ] Existing `channels.matrix` single-account config loads unchanged.
- [ ] Existing `channels.matrix.accounts.*` config loads unchanged.
- [ ] Existing `channels.matrix.defaultAccount` behavior is preserved.
- [ ] Existing SecretRef password config continues to validate and resolve.
- [ ] Deprecated Matrix-only keys are auto-repaired by startup and doctor with clear change reporting.
- [x] Existing `channels.matrix` single-account config loads unchanged.
- [x] Existing `channels.matrix.accounts.*` config loads unchanged.
- [x] Existing `channels.matrix.defaultAccount` behavior is preserved.
- [x] Existing SecretRef password config continues to validate and resolve.
- [x] Deprecated Matrix-only keys are auto-repaired by startup and doctor with clear change reporting.
### State compatibility
- [ ] Current canonical `credentials/matrix/*` credentials are reused with no prompt.
- [ ] Current canonical `matrix/accounts/*` runtime state is reused with no prompt.
- [ ] Oldest flat legacy Matrix crypto and sync store is migrated automatically to account-scoped storage.
- [ ] Multi-account state remains isolated after migration.
- [x] Current canonical `credentials/matrix/*` credentials are reused with no prompt.
- [x] Current canonical `matrix/accounts/*` runtime state is reused with no prompt.
- [x] Oldest flat legacy Matrix crypto and sync store is migrated automatically to account-scoped storage.
- [x] Legacy Matrix encrypted backup material is imported automatically when it can be resolved safely.
- [x] Backed-up Matrix room keys are restored automatically on startup after encrypted-state prep.
- [x] Multi-account state remains isolated after migration.
### Plugin and install compatibility
- [ ] Existing npm-installed `@openclaw/matrix` updates in place and remains enabled.
- [ ] `plugins.installs.matrix` continues to update correctly after the cutover.
- [ ] Stale custom path installs are detected and produce exact repair messaging.
- [x] Existing npm-installed `@openclaw/matrix` updates in place and remains enabled.
- [x] `plugins.installs.matrix` continues to update correctly after the cutover.
- [x] Stale custom path installs are detected and produce exact repair messaging.
### Public surface
- [ ] `openclaw matrix ...` exposes the verification and account commands that the new Matrix implementation owns.
- [ ] `matrix.verify.*` gateway methods work.
- [ ] All bindings, ACP, thread, and session flows use `matrix`, not `matrix-js`.
- [ ] No shipped docs, help, schema output, or package exports reference `matrix-js`.
- [x] `openclaw matrix ...` exposes the verification and account commands that the new Matrix implementation owns.
- [x] `matrix.verify.*` gateway methods work.
- [x] All bindings, ACP, thread, and session flows use `matrix`, not `matrix-js`.
- [x] No shipped docs, help, schema output, or package exports reference `matrix-js`.
### Regression coverage
- [ ] Startup auto-migration path.
- [ ] Doctor `--fix` Matrix migration path.
- [ ] Update-triggered doctor path.
- [ ] Route bindings and ACP bindings with `match.channel: "matrix"`.
- [ ] Thread binding spawn gating and routing on `matrix`.
- [ ] Plugin install and update records for `matrix`.
- [x] Startup auto-migration path.
- [x] Doctor `--fix` Matrix migration path.
- [x] Legacy encrypted-state prep and startup restore path.
- [x] Update-triggered doctor path.
- [x] Route bindings and ACP bindings with `match.channel: "matrix"`.
- [x] Thread binding spawn gating and routing on `matrix`.
- [x] Plugin install and update records for `matrix`.
### Acceptance criteria
- [ ] A current public Matrix user can update and keep using `channels.matrix` without editing config.
- [ ] Automatic migration covers every deterministic case.
- [ ] Every non-deterministic case produces explicit next steps.
- [ ] No public `matrix-js` surface remains in the shipped product.
- [x] A current public Matrix user can update and keep using `channels.matrix` without editing config.
- [x] Automatic migration covers every deterministic case.
- [x] Every non-deterministic case produces explicit next steps.
- [x] No public `matrix-js` surface remains in the shipped product.
## Assumptions and defaults

View File

@@ -651,7 +651,7 @@ Run multiple accounts per channel (each with its own `accountId`):
### Other extension channels
Many extension channels are configured as `channels.<id>` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch).
Matrix-js also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`.
Matrix also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`.
See the full channel index: [Channels](/channels).
### Group chat mention gating

View File

@@ -0,0 +1,243 @@
---
summary: "How OpenClaw upgrades the previous Matrix plugin in place, including encrypted-state recovery limits and manual recovery steps."
read_when:
- Upgrading an existing Matrix installation
- Migrating encrypted Matrix history and device state
title: "Matrix migration"
---
# Matrix migration
This page covers upgrades from the previous public `matrix` plugin to the current implementation.
For most users, the upgrade is in place:
- the plugin stays `@openclaw/matrix`
- the channel stays `matrix`
- your config stays under `channels.matrix`
- cached credentials stay under `~/.openclaw/credentials/matrix/`
- runtime state stays under `~/.openclaw/matrix/`
You do not need to rename config keys or reinstall the plugin under a new name.
## What the migration does automatically
When the gateway starts, and when you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw tries to repair old Matrix state automatically.
Automatic migration covers:
- reusing your cached Matrix credentials
- keeping the same account selection and `channels.matrix` config
- moving the oldest flat Matrix sync store into the current account-scoped location
- moving the oldest flat Matrix crypto store into the current account-scoped location when the target account can be resolved safely
- extracting a previously saved Matrix room-key backup decryption key from the old rust crypto store, when that key exists locally
- restoring backed-up room keys into the new crypto store on the next Matrix startup
## What the migration cannot do automatically
The previous public Matrix plugin did **not** automatically create Matrix room-key backups. It persisted local crypto state and requested device verification, but it did not guarantee that your room keys were backed up to the homeserver.
That means some encrypted installs can only be migrated partially.
OpenClaw cannot automatically recover:
- local-only room keys that were never backed up
- encrypted state when the target Matrix account cannot be resolved yet because `homeserver`, `userId`, or `accessToken` are still unavailable
- custom plugin path installs that now point at a missing directory
- a missing recovery key when the old store had backed-up keys but did not keep the decryption key locally
If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade.
## Recommended upgrade flow
1. Update OpenClaw and the Matrix plugin normally.
2. Run:
```bash
openclaw doctor --fix
```
3. Start or restart the gateway.
4. Check current verification and backup state:
```bash
openclaw matrix verify status
openclaw matrix verify backup status
```
5. If OpenClaw tells you a recovery key is needed, run:
```bash
openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"
```
6. If this device is still unverified, run:
```bash
openclaw matrix verify device "<your-recovery-key>"
```
7. If no server-side key backup exists yet, create one for future recoveries:
```bash
openclaw matrix verify bootstrap
```
## How encrypted migration works
Encrypted migration is a two-stage process:
1. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store.
2. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
3. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically.
If the old store reports room keys that were never backed up, OpenClaw warns instead of pretending recovery succeeded.
## Common messages and what they mean
### Upgrade and detection messages
`Matrix plugin upgraded in place.`
- Meaning: the old on-disk Matrix state was detected and migrated into the current layout.
- What to do: nothing unless the same output also includes warnings.
`Legacy Matrix state detected at ... but channels.matrix is not configured yet.`
- Meaning: old Matrix state exists, but OpenClaw cannot map it to a current Matrix account because Matrix is not configured.
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
`Legacy Matrix state detected at ... but the new account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).`
- Meaning: OpenClaw found old state, but it still cannot determine the exact current account/device root.
- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials exist.
`Matrix legacy sync store not migrated because the target already exists (...)`
- Meaning: the new account-scoped location already has a sync or crypto store, so OpenClaw did not overwrite it automatically.
- What to do: verify that the current account is the correct one before manually removing or moving the conflicting target.
`Failed migrating Matrix legacy sync store (...)` or `Failed migrating Matrix legacy crypto store (...)`
- Meaning: OpenClaw tried to move old Matrix state but the filesystem operation failed.
- What to do: inspect filesystem permissions and disk state, then rerun `openclaw doctor --fix`.
`Legacy Matrix encrypted state detected at ... but channels.matrix is not configured yet.`
- Meaning: OpenClaw found an old encrypted Matrix store, but there is no current Matrix config to attach it to.
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
`Legacy Matrix encrypted state detected at ... but the account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).`
- Meaning: the encrypted store exists, but OpenClaw cannot safely decide which current account/device it belongs to.
- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials are available.
### Encrypted-state recovery messages
`matrix: restored X/Y room key(s) from legacy encrypted-state backup`
- Meaning: backed-up room keys were restored successfully into the new crypto store.
- What to do: usually nothing.
`matrix: N legacy local-only room key(s) were never backed up and could not be restored automatically`
- Meaning: some old room keys existed only in the old local store and had never been uploaded to Matrix backup.
- What to do: expect some old encrypted history to remain unavailable unless you can recover those keys manually from another verified client.
`Legacy Matrix encrypted state for account "..." has backed-up room keys, but no local backup decryption key was found. Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
`Legacy Matrix backup key was found for account "...", but .../recovery-key.json already contains a different recovery key. Leaving the existing file unchanged.`
- Meaning: OpenClaw detected a backup key conflict and refused to overwrite the current recovery-key file automatically.
- What to do: verify which recovery key is correct before retrying any restore command.
`Legacy Matrix encrypted state for account "..." cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`
- Meaning: this is the hard limit of the old storage format.
- What to do: backed-up keys can still be restored, but local-only encrypted history may remain unavailable.
`matrix: failed restoring room keys from legacy encrypted-state backup: ...`
- Meaning: the new plugin attempted restore but Matrix returned an error.
- What to do: run `openclaw matrix verify backup status`, then retry with `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"` if needed.
### Manual recovery messages
`Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.`
- Meaning: OpenClaw knows you should have a backup key, but it is not active on this device.
- What to do: run `openclaw matrix verify backup restore`, or pass `--recovery-key` if needed.
`Store a recovery key with 'openclaw matrix verify device <key>', then run 'openclaw matrix verify backup restore'.`
- Meaning: this device does not currently have the recovery key stored.
- What to do: verify the device with your recovery key first, then restore the backup.
`Backup key mismatch on this device. Re-run 'openclaw matrix verify device <key>' with the matching recovery key.`
- Meaning: the stored key does not match the active Matrix backup.
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"` with the correct key.
`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device <key>'.`
- Meaning: the backup exists, but this device does not trust the cross-signing chain strongly enough yet.
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"`.
`Matrix recovery key is required`
- Meaning: you tried a recovery step without supplying a recovery key when one was required.
- What to do: rerun the command with your recovery key.
`Invalid Matrix recovery key: ...`
- Meaning: the provided key could not be parsed or did not match the expected format.
- What to do: retry with the exact recovery key from your Matrix client or recovery-key file.
`Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.`
- Meaning: the key was applied, but the device still could not complete verification.
- What to do: confirm you used the correct key and that cross-signing is available on the account, then retry.
`Matrix key backup is not active on this device after loading from secret storage.`
- Meaning: secret storage did not produce an active backup session on this device.
- What to do: verify the device first, then recheck with `openclaw matrix verify backup status`.
`Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device <key>' first.`
- Meaning: this device cannot restore from secret storage until device verification is complete.
- What to do: run `openclaw matrix verify device "<your-recovery-key>"` first.
### Custom plugin install messages
`Matrix is installed from a custom path that no longer exists: ...`
- Meaning: your plugin install record points at a local path that is gone.
- What to do: reinstall with `openclaw plugins install @openclaw/matrix`, or if you are running from a repo checkout, `openclaw plugins install ./extensions/matrix`.
## If encrypted history still does not come back
Run these checks in order:
```bash
openclaw matrix verify status --verbose
openclaw matrix verify backup status --verbose
openclaw matrix verify backup restore --recovery-key "<your-recovery-key>" --verbose
```
If the backup restores successfully but some old rooms are still missing history, those missing keys were probably never backed up by the previous plugin.
## Related pages
- [Matrix](/channels/matrix)
- [Doctor](/gateway/doctor)
- [Migrating](/install/migrating)
- [Plugins](/tools/plugin)

View File

@@ -79,7 +79,7 @@ Required feature flags for thread-bound ACP:
- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
- Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
### Thread supporting channels
@@ -87,7 +87,7 @@ Required feature flags for thread-bound ACP:
- Any channel adapter that exposes session/thread binding capability.
- Current built-in support:
- Discord threads/channels
- Matrix-js room threads and DMs
- Matrix room threads and DMs
- Telegram topics (forum topics in groups/supergroups and DM topics)
- Plugin channels can add support through the same binding interface.
@@ -100,7 +100,7 @@ For non-ephemeral workflows, configure persistent ACP bindings in top-level `bin
- `bindings[].type="acp"` marks a persistent ACP conversation binding.
- `bindings[].match` identifies the target conversation:
- Discord channel or thread: `match.channel="discord"` + `match.peer.id="<channelOrThreadId>"`
- Matrix room or thread: `match.channel="matrix-js"` + `match.peer.id="<roomIdOrThreadRootEventId>"`
- Matrix room or thread: `match.channel="matrix"` + `match.peer.id="<roomIdOrThreadRootEventId>"`
- Telegram forum topic: `match.channel="telegram"` + `match.peer.id="<chatId>:topic:<topicId>"`
- `bindings[].agentId` is the owning OpenClaw agent id.
- Optional ACP overrides live under `bindings[].acp`:
@@ -378,7 +378,7 @@ Notes:
- On non-thread binding surfaces, default behavior is effectively `off`.
- Thread-bound spawn requires channel policy support:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
- Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true`
- Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true`
## ACP controls
@@ -479,7 +479,7 @@ Core ACP baseline:
}
```
Thread binding config is channel-adapter specific. Example for Discord and Matrix-js:
Thread binding config is channel-adapter specific. Example for Discord and Matrix:
```json5
{
@@ -497,7 +497,7 @@ Thread binding config is channel-adapter specific. Example for Discord and Matri
spawnAcpSessions: true,
},
},
"matrix-js": {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
@@ -510,7 +510,7 @@ Thread binding config is channel-adapter specific. Example for Discord and Matri
If thread-bound ACP spawn does not work, verify the adapter feature flag first:
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true`
- Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true`
See [Configuration Reference](/gateway/configuration-reference).

View File

@@ -166,7 +166,7 @@ authoring plugins:
`openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`,
`openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`,
`openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`,
`openclaw/plugin-sdk/matrix-js`,
`openclaw/plugin-sdk/matrix`,
`openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`,
`openclaw/plugin-sdk/memory-lancedb`,
`openclaw/plugin-sdk/minimax-portal-auth`,

View File

@@ -17,7 +17,7 @@ Channel notes:
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
- **Matrix-js**: empty `emoji` removes the bot account's own reactions on the message; `remove: true` removes just that emoji; inbound reaction notifications on bot-authored messages are controlled by `reactionNotifications`.
- **Matrix**: empty `emoji` removes the bot account's own reactions on the message; `remove: true` removes just that emoji; inbound reaction notifications on bot-authored messages are controlled by `reactionNotifications`.
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.

View File

@@ -100,7 +100,7 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a
### Thread supporting channels
- `sessions_spawn` with `thread: true`: currently supported on Discord only.
- Manual thread/conversation controls are supported on Discord, Matrix-js, and Telegram.
- Manual thread/conversation controls are supported on Discord, Matrix, and Telegram.
- Available controls: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`
Quick flow:

View File

@@ -1,120 +0,0 @@
# Legacy Matrix Parity Gap Audit
Audit date: February 23, 2026
Scope:
- Baseline spec: `<repo-root>/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md`
- Compared implementations:
- Legacy: `<repo-root>/extensions/matrix`
- New: `<repo-root>/extensions/matrix-js`
Method:
- Static code comparison and targeted file inspection.
- Runtime validation executed for matrix-js test suites and project build.
Status legend:
- `PASS (static)` = code-level parity confirmed.
- `NEEDS UPDATING` = concrete parity/coexistence gap found.
- `UNVERIFIED (runtime)` = requires executing tests/integration flows.
## Summary
- Overall feature parity with legacy behavior: strong at code level.
- Previously identified dual-plugin coexistence blockers are resolved in code.
- Matrix-js regression tests pass (`27` files, `112` tests).
- Full repository build passes after the matrix-js namespace/storage changes.
- Remaining runtime validation gap: explicit side-by-side legacy `matrix` + `matrix-js` integration run.
## Coexistence Gaps (Current Status)
1. `PASS (static)`: Channel identity is consistent as `matrix-js` across metadata and runtime registration.
- Evidence:
- `<repo-root>/extensions/matrix-js/index.ts:7`
- `<repo-root>/extensions/matrix-js/openclaw.plugin.json:2`
- `<repo-root>/extensions/matrix-js/src/channel.ts:41`
- `<repo-root>/extensions/matrix-js/src/channel.ts:99`
2. `PASS (static)`: Config namespace is consistently `channels.matrix-js`.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/channel.ts:116`
- `<repo-root>/extensions/matrix-js/src/channel.ts:125`
- `<repo-root>/extensions/matrix-js/src/channel.ts:319`
- `<repo-root>/extensions/matrix-js/src/onboarding.ts:17`
- `<repo-root>/extensions/matrix-js/src/onboarding.ts:174`
- `<repo-root>/extensions/matrix-js/src/matrix/send/client.ts:22`
- `<repo-root>/extensions/matrix-js/src/matrix/client/config.ts:125`
3. `PASS (static)`: Outbound/inbound channel tags and routing context emit `matrix-js`.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/outbound.ts:20`
- `<repo-root>/extensions/matrix-js/src/outbound.ts:36`
- `<repo-root>/extensions/matrix-js/src/outbound.ts:49`
- `<repo-root>/extensions/matrix-js/src/matrix/send.ts:55`
- `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts:496`
- `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts:509`
4. `PASS (static)`: Matrix-js now uses isolated storage namespace/prefixes.
- Evidence:
- `<repo-root>/extensions/matrix-js/src/matrix/credentials.ts:31`
- `<repo-root>/extensions/matrix-js/src/matrix/client/storage.ts:42`
- `<repo-root>/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts:127`
- `<repo-root>/extensions/matrix-js/src/matrix/client/create-client.ts:43`
## Parity Matrix (Spec Section 16, Pre-Filled)
| Check | Status | Evidence |
| ---------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Config schema keys and defaults are equivalent | PASS (static) | `<repo-root>/extensions/matrix/src/config-schema.ts` vs `<repo-root>/extensions/matrix-js/src/config-schema.ts` (no semantic diffs) |
| Auth precedence (config/env/token/cache/password/register) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/config.ts` |
| Bun runtime rejection behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/runtime.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/index.ts` |
| Startup/shutdown lifecycle and status updates match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/channel.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/index.ts` |
| DM detection heuristics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/direct.ts` |
| DM/group allowlist + pairing flow matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/allowlist.ts` |
| Mention detection (`m.mentions`, formatted_body links, regex) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/mentions.ts` |
| Control-command authorization gate behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts` |
| Inbound poll normalization matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/poll-types.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts` |
| Inbound location normalization matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/location.ts` |
| Inbound media download/decrypt/size-limit behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/media.ts` |
| Reply dispatch + typing + ack reaction + read receipts match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/handler.ts`, `<repo-root>/extensions/matrix-js/src/matrix/monitor/replies.ts` |
| Thread handling (`threadReplies`) matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/threads.ts` |
| `replyToMode` handling for single/multi reply flows matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/monitor/replies.ts` |
| Outbound text chunking, markdown, and formatting behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send.ts`, `<repo-root>/extensions/matrix-js/src/matrix/send/formatting.ts` |
| Outbound media encryption/voice/thumbnail/duration behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send/media.ts` |
| Outbound poll payload behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/send.ts`, `<repo-root>/extensions/matrix-js/src/matrix/poll-types.ts` |
| Action gating and action semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/actions.ts`, `<repo-root>/extensions/matrix-js/src/tool-actions.ts`, `<repo-root>/extensions/matrix-js/src/matrix/actions/*` |
| Verification action flow and summary semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/actions/verification.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/verification-manager.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts` |
| Directory live lookup + target resolution ambiguity handling matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/directory-live.ts`, `<repo-root>/extensions/matrix-js/src/resolve-targets.ts` |
| Probe/status reporting fields match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/probe.ts`, `<repo-root>/extensions/matrix-js/src/channel.ts` |
| Storage layout and credential persistence semantics match legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/client/storage.ts`, `<repo-root>/extensions/matrix-js/src/matrix/credentials.ts` |
| HTTP hardening and decrypt retry behavior matches legacy | PASS (static) | `<repo-root>/extensions/matrix-js/src/matrix/sdk/http-client.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts`, `<repo-root>/extensions/matrix-js/src/matrix/sdk.ts` |
## Runtime Validation Status
- `PASS (runtime)`: matrix-js regression run succeeded via `pnpm test extensions/matrix-js/src` (`27` files, `112` tests).
- `PASS (runtime)`: build/type pipeline succeeded via `pnpm build`.
- `UNVERIFIED (runtime)`: side-by-side load of legacy `matrix` plus `matrix-js` with independent config.
Recommended commands for final coexistence sign-off:
```bash
pnpm test extensions/matrix/src
pnpm test extensions/matrix-js/src
pnpm build
```
## Suggested Next Fix Batch
1. Add explicit coexistence integration tests:
- Load both legacy `matrix` and `matrix-js` in one runtime with independent config + pairing state.
2. Validate state migration behavior (if required by product decision):
- Decide whether `matrix-js` should intentionally read legacy `channels.matrix`/`credentials/matrix` during transition or stay fully isolated.

View File

@@ -1,397 +0,0 @@
# Legacy Matrix Plugin Parity Spec
This document defines the expected behavior of the **legacy Matrix plugin** (`extensions/matrix`) so the new **matrix-js plugin** (`extensions/matrix-js`) can be verified for feature parity.
## 1. Scope
- Legacy source of truth:
- `extensions/matrix/index.ts`
- `extensions/matrix/src/channel.ts`
- `extensions/matrix/src/**/*.ts`
- New implementation under test:
- `extensions/matrix-js/**`
- Goal: matrix-js should preserve user-visible and operator-visible behavior unless explicitly changed.
## 2. Parity Levels
- `MUST`: required parity for GA.
- `SHOULD`: desirable parity; acceptable temporary delta if documented.
- `NICE`: optional parity.
## 3. Channel + Plugin Contract (MUST)
- Plugin id remains `matrix`; channel id exposed to runtime is `matrix` in legacy.
- Channel metadata parity:
- label/selection/docs path/blurb/order/quickstart allowFrom behavior.
- Channel capabilities parity:
- `chatTypes`: direct, group, thread
- `polls`: true
- `reactions`: true
- `threads`: true
- `media`: true
- Reload behavior parity:
- config prefixes include `channels.matrix`.
- Pairing behavior parity:
- pairing id label, allow-entry normalization, approval notification message behavior.
## 4. Configuration Contract (MUST)
Legacy schema lives in `extensions/matrix/src/config-schema.ts` and `extensions/matrix/src/types.ts`.
### 4.1 Core fields
- `enabled?: boolean`
- Auth: `homeserver`, `userId`, `accessToken`, `password`, `register`, `deviceId`, `deviceName`
- Sync/runtime: `initialSyncLimit`, `encryption`
- Access control:
- `allowlistOnly`
- `groupPolicy`: `open|allowlist|disabled`
- `groupAllowFrom`
- `dm.policy`: `pairing|allowlist|open|disabled`
- `dm.allowFrom`
- Room policy:
- `groups` (preferred) and `rooms` (legacy alias)
- room fields: `enabled`, `allow`, `requireMention`, `tools`, `autoReply`, `users`, `skills`, `systemPrompt`
- Reply/thread behavior:
- `replyToMode`: `off|first|all`
- `threadReplies`: `off|inbound|always`
- Output shaping:
- `markdown`, `textChunkLimit`, `chunkMode`, `responsePrefix`
- Media + invites:
- `mediaMaxMb`
- `autoJoin`: `always|allowlist|off`
- `autoJoinAllowlist`
- Action gates:
- `actions.reactions|messages|pins|memberInfo|channelInfo|verification`
### 4.2 Defaults and effective behavior
- DM default policy: `pairing`.
- Group mention default: mention required in rooms unless room override allows auto-reply.
- `replyToMode` default: `off`.
- `threadReplies` default: `inbound`.
- `autoJoin` default: `always`.
- Legacy global hard text max remains 4000 chars per chunk for matrix sends/replies.
- When `allowlistOnly=true`, policies are effectively tightened:
- group `open` behaves as `allowlist`
- DM policy behaves as `allowlist` unless explicitly disabled.
## 5. Account Model + Resolution (MUST)
- Account listing/resolution behavior in `extensions/matrix/src/matrix/accounts.ts`:
- supports top-level single account fallback (`default` account semantics).
- supports per-account map and normalized account IDs.
- per-account config deep-merges known nested sections (`dm`, `actions`) over base config.
- Account configured state logic parity:
- configured when homeserver exists and one of:
- access token
- userId+password
- matching stored credentials.
## 6. Auth + Client Bootstrap (MUST)
Legacy auth behavior in `extensions/matrix/src/matrix/client/config.ts`:
- Config/env resolution precedence:
- config values override env values.
- env vars: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN`, `MATRIX_PASSWORD`, `MATRIX_REGISTER`, `MATRIX_DEVICE_ID`, `MATRIX_DEVICE_NAME`.
- Token-first behavior:
- with access token, `whoami` resolves missing `userId` and/or `deviceId`.
- Credential cache behavior:
- reuses cached credentials when config matches homeserver+user (or homeserver-only token flow).
- updates `lastUsedAt` when reused.
- Password login behavior:
- login with `m.login.password` when no token.
- Register mode behavior:
- if login fails and `register=true`, attempts registration and then login-equivalent token flow.
- registration mode prepares backup snapshot and finalizes config by turning off `register` and removing stale inline token.
- Bun runtime must be rejected (Node required).
## 7. Runtime/Connection Lifecycle (MUST)
- Gateway startup path (`channel.ts` + `monitor/index.ts`) must:
- resolve auth,
- resolve shared client,
- attach monitor handlers,
- start sync,
- report runtime status fields.
- Shutdown behavior:
- client is stopped on abort,
- active client reference cleared.
- Startup lock behavior:
- startup import race is serialized via lock in `channel.ts`.
## 8. Inbound Event Processing (MUST)
Legacy handler logic: `extensions/matrix/src/matrix/monitor/handler.ts`.
### 8.1 Event eligibility
- Processes:
- `m.room.message`
- poll start events (`m.poll.start` + MSC aliases)
- location events (`m.location` and location msgtype)
- Ignores:
- redacted events
- self-sent events
- old pre-startup events
- edit relation events (`m.replace`)
- encrypted raw payloads (expects decrypted bridge events)
### 8.2 DM/group detection
- DM detection chain (`monitor/direct.ts`):
- `m.direct` cache,
- member-count heuristic (2 users),
- `is_direct` member-state fallback.
### 8.3 Access control + allowlists
- DM policy behavior:
- `disabled`: no DM processing.
- `open`: process all DMs.
- `allowlist`: process only matching allowlist.
- `pairing`: create pairing request/code for unauthorized sender and send approval instructions.
- Group policy behavior:
- `disabled`: ignore rooms.
- `allowlist`: room must exist in allowlisted rooms map (or wildcard) and pass optional sender constraints.
- `open`: allow rooms, still mention-gated by default.
- Group sender gating:
- room-level `users` allowlist if configured.
- `groupAllowFrom` fallback when room users list not set.
### 8.4 Mention + command gate behavior
- Mention detection parity:
- `m.mentions.user_ids`
- `m.mentions.room`
- `formatted_body` matrix.to links (plain and URL-encoded)
- mention regex patterns from core mention config
- Default room behavior requires mention unless room policy overrides.
- Control command bypass behavior:
- unauthorized control commands are dropped in group contexts.
### 8.5 Input normalization
- Poll start events converted to normalized text payload.
- Location events converted to normalized location text + context fields.
- mxc media downloaded (and decrypted when file payload present) with max-byte enforcement.
### 8.6 Context/session/routing
- Builds context with matrix-specific fields:
- From/To/SessionKey/MessageSid/ReplyToId/MessageThreadId/MediaPath/etc.
- Resolves per-agent route via core routing.
- Persists inbound session metadata and updates last-route for DM contexts.
### 8.7 Reply delivery
- Typing indicators start/stop around reply dispatch.
- Reply prefix/model-selection behavior uses core reply options.
- Room-level `skills` filter and `systemPrompt` are applied.
- Reply delivery semantics:
- `replyToMode` controls how often replyTo is used (`off|first|all`).
- thread target suppresses plain replyTo fallback.
- chunking and markdown-table conversion parity required.
### 8.8 Side effects
- Optional ack reaction based on `messages.ackReaction` + scope rules.
- Read receipt sent for inbound event IDs.
- System event enqueued after successful reply.
## 9. Outbound Sending Contract (MUST)
Legacy send behavior: `extensions/matrix/src/matrix/send.ts` and `send/*`.
### 9.1 Text
- Requires text or media; empty text without media is error.
- Resolves target IDs from `matrix:/room:/channel:/user:/@user/#alias` forms.
- Markdown tables converted via core table mode.
- Markdown converted to Matrix HTML formatting.
- Chunking respects configured limit but hard-caps at 4000.
- Thread relation behavior:
- `threadId` -> `m.thread` relation.
- otherwise optional reply relation.
### 9.2 Media
- Loads media via core media loader with size limits.
- Upload behavior:
- encrypts media in encrypted rooms when crypto available.
- otherwise plain upload.
- Includes metadata:
- mimetype/size/duration,
- image dimensions/thumbnail when available.
- Voice behavior:
- if `audioAsVoice=true` and compatible audio, send as voice payload (`org.matrix.msc3245.voice`).
- Caption/follow-up behavior:
- first chunk is caption,
- remaining text chunks become follow-up messages.
### 9.3 Polls
- Supports `sendPoll` with MSC3381 payload (`m.poll.start`) + fallback text.
- Supports thread relation for polls when thread ID present.
### 9.4 Reactions + receipts + typing
- Supports sending reactions (`m.reaction` annotation).
- Supports typing state and read receipts.
## 10. Tool/Action Contract (MUST)
Legacy action adapter: `src/actions.ts`, `src/tool-actions.ts`, `src/matrix/actions/*`.
### 10.1 Action availability gates
- Baseline actions include `send` and poll path support.
- Optional gated actions:
- reactions: `react`, `reactions`
- messages: `read`, `edit`, `delete`
- pins: `pin`, `unpin`, `list-pins`
- member info: `member-info`
- channel info: `channel-info`
- verification: `permissions` (only with encryption enabled + gate enabled)
### 10.2 Action semantics
- Send/edit/delete/read messages behavior parity:
- edit uses `m.replace` + `m.new_content` conventions.
- read uses `/rooms/{room}/messages` with before/after pagination tokens.
- Reaction semantics parity:
- list aggregates count per emoji and unique users.
- remove only current-user reactions (optional emoji filter).
- Pin semantics parity:
- state event `m.room.pinned_events` update/read.
- list includes resolvable summarized events.
- Member info semantics parity:
- profile display name/avatar available,
- membership/power currently returned as `null` placeholders.
- Room info semantics parity:
- includes name/topic/canonicalAlias/memberCount where retrievable.
- Verification semantics parity:
- status/list/request/accept/cancel/start/generate-qr/scan-qr/sas/confirm/mismatch/confirm-qr flows.
## 11. Directory + Target Resolution (MUST)
### 11.1 Live directory
- Peer lookup uses Matrix user directory search endpoint.
- Group lookup behavior:
- alias input (`#...`) resolves via directory API,
- room ID input (`!...`) is accepted directly,
- otherwise scans joined rooms by room name.
### 11.2 Resolver behavior
- User resolver rules:
- full user IDs resolve directly,
- otherwise requires exact unique match from live directory.
- Group resolver rules:
- prefers exact match; otherwise first candidate with note.
- Room config key normalization behavior:
- supports `matrix:`/`room:`/`channel:` prefixes and canonical IDs.
## 12. Status + Probing (MUST)
- Probe behavior (`matrix/probe.ts`):
- validates homeserver + token,
- initializes client,
- resolves user via client and returns elapsed time/status.
- Channel status snapshot includes:
- configured/baseUrl/running/last start-stop/error/probe/last probe/inbound/outbound fields.
## 13. Storage + Security + E2EE (MUST)
### 13.1 Credential/state paths
- Credentials persisted in state dir under `credentials/matrix`.
- Per-account credential filename semantics preserved.
- Matrix storage paths include account key + homeserver key + user key + token hash.
- Legacy storage migration behavior preserved.
### 13.2 HTTP hardening
- Matrix HTTP client behavior parity:
- blocks unexpected absolute endpoints,
- blocks cross-protocol redirects,
- strips auth headers on cross-origin redirect,
- supports request timeout.
### 13.3 Encryption
- Rust crypto initialization and bootstrap behavior preserved.
- Decryption bridge behavior preserved:
- encrypted event handling,
- failed decrypt retries,
- retry caps and signal-driven retry.
- Recovery key behavior preserved:
- persisted securely (0600),
- reused for secret storage callbacks,
- handles default key rebind and recreation when needed.
## 14. Onboarding UX Contract (SHOULD)
Legacy onboarding (`src/onboarding.ts`) should remain equivalent:
- checks matrix SDK availability and offers install flow,
- supports env-detected quick setup,
- supports token/password/register auth choice,
- validates homeserver URL and user ID format,
- supports DM policy and allowFrom prompt with user resolution,
- supports optional group policy and group room selection.
## 15. Known Legacy Quirks To Track (NEEDS UPDATING)
These should be explicitly reviewed during parity auditing (either preserve intentionally or fix intentionally):
- `supportsAction`/`poll` behavior in action adapter is non-obvious and should be validated end-to-end.
- Some account-aware callsites pass `accountId` through paths where underlying helpers may not consistently consume it.
- Legacy room/member info actions include placeholder/null fields (`altAliases`, `membership`, `powerLevel`).
## 16. Parity Test Matrix
Use this checklist while validating `extensions/matrix-js`:
- [ ] Config schema keys and defaults are equivalent.
- [ ] Auth precedence (config/env/token/cache/password/register) matches legacy.
- [ ] Bun runtime rejection behavior matches legacy.
- [ ] Startup/shutdown lifecycle and status updates match legacy.
- [ ] DM detection heuristics match legacy.
- [ ] DM/group allowlist + pairing flow matches legacy.
- [ ] Mention detection (`m.mentions`, formatted_body links, regex) matches legacy.
- [ ] Control-command authorization gate behavior matches legacy.
- [ ] Inbound poll normalization matches legacy.
- [ ] Inbound location normalization matches legacy.
- [ ] Inbound media download/decrypt/size-limit behavior matches legacy.
- [ ] Reply dispatch + typing + ack reaction + read receipts match legacy.
- [ ] Thread handling (`threadReplies`) matches legacy.
- [ ] `replyToMode` handling for single/multi reply flows matches legacy.
- [ ] Outbound text chunking, markdown, and formatting behavior matches legacy.
- [ ] Outbound media encryption/voice/thumbnail/duration behavior matches legacy.
- [ ] Outbound poll payload behavior matches legacy.
- [ ] Action gating and action semantics match legacy.
- [ ] Verification action flow and summary semantics match legacy.
- [ ] Directory live lookup + target resolution ambiguity handling matches legacy.
- [ ] Probe/status reporting fields match legacy.
- [ ] Storage layout and credential persistence semantics match legacy.
- [ ] HTTP hardening and decrypt retry behavior matches legacy.
## 17. Minimum Regression Commands
Run at least:
```bash
pnpm vitest extensions/matrix/src/**/*.test.ts
pnpm vitest extensions/matrix-js/src/**/*.test.ts
pnpm build
```
If behavior differs intentionally, document the delta under this spec with:
- reason,
- user impact,
- migration note,
- tests proving new intended behavior.

View File

@@ -1,98 +0,0 @@
import type {
GatewayRequestHandlerOptions,
OpenClawPluginApi,
} from "openclaw/plugin-sdk/matrix-js";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix-js";
import { matrixPlugin } from "./src/channel.js";
import { registerMatrixJsCli } from "./src/cli.js";
import {
bootstrapMatrixVerification,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "./src/matrix/actions/verification.js";
import { setMatrixRuntime } from "./src/runtime.js";
const plugin = {
id: "matrix-js",
name: "Matrix-js",
description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: err instanceof Error ? err.message : String(err) });
};
api.registerGatewayMethod(
"matrix-js.verify.recoveryKey",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const key = typeof params?.key === "string" ? params.key : "";
if (!key.trim()) {
respond(false, { error: "key required" });
return;
}
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const result = await verifyMatrixRecoveryKey(key, { accountId });
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.bootstrap",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const recoveryKey =
typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined;
const forceResetCrossSigning = params?.forceResetCrossSigning === true;
const result = await bootstrapMatrixVerification({
accountId,
recoveryKey,
forceResetCrossSigning,
});
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix-js.verify.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const includeRecoveryKey = params?.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey });
respond(true, status);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerCli(
({ program }) => {
registerMatrixJsCli({ program });
},
{ commands: ["matrix-js"] },
);
},
};
export default plugin;

View File

@@ -1,9 +0,0 @@
{
"id": "matrix-js",
"channels": ["matrix-js"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -1,37 +0,0 @@
{
"name": "@openclaw/matrix-js",
"version": "2026.2.22",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "matrix-js",
"label": "Matrix-js",
"selectionLabel": "Matrix-js (plugin)",
"docsPath": "/channels/matrix-js",
"docsLabel": "matrix-js",
"blurb": "open protocol; install the plugin to enable.",
"order": 70,
"quickstartAllowFrom": true
},
"install": {
"npmSpec": "@openclaw/matrix-js",
"localPath": "extensions/matrix-js",
"defaultChoice": "npm"
}
}
}

View File

@@ -1,246 +0,0 @@
import {
createActionGate,
readNumberParam,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionContext,
type ChannelMessageActionName,
type ChannelToolSend,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { handleMatrixAction } from "./tool-actions.js";
import type { CoreConfig } from "./types.js";
const MATRIX_JS_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"poll-vote",
"react",
"reactions",
"read",
"edit",
"delete",
"pin",
"unpin",
"list-pins",
"member-info",
"channel-info",
"permissions",
]);
function createMatrixJsExposedActions() {
return new Set<ChannelMessageActionName>(["poll", ...MATRIX_JS_PLUGIN_HANDLED_ACTIONS]);
}
export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions);
const actions = createMatrixJsExposedActions();
if (gate("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (gate("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (gate("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (gate("memberInfo")) {
actions.add("member-info");
}
if (gate("channelInfo")) {
actions.add("channel-info");
}
if (account.config.encryption === true && gate("verification")) {
actions.add("permissions");
}
return Array.from(actions);
},
supportsAction: ({ action }) => MATRIX_JS_PLUGIN_HANDLED_ACTIONS.has(action),
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
return { to };
},
handleAction: async (ctx: ChannelMessageActionContext) => {
const { action, params, cfg, accountId } = ctx;
const dispatch = async (actionParams: Record<string, unknown>) =>
await handleMatrixAction(
{
...actionParams,
...(accountId ? { accountId } : {}),
},
cfg as CoreConfig,
);
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
readStringParam(params, "channelId") ??
readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
return await dispatch({
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
replyToId: replyTo ?? undefined,
threadId: threadId ?? undefined,
});
}
if (action === "poll-vote") {
return await dispatch({
...params,
action: "pollVote",
});
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", { required: true });
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await dispatch({
action: "react",
roomId: resolveRoomId(),
messageId,
emoji,
remove,
});
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await dispatch({
action: "reactions",
roomId: resolveRoomId(),
messageId,
limit,
});
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await dispatch({
action: "readMessages",
roomId: resolveRoomId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
});
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await dispatch({
action: "editMessage",
roomId: resolveRoomId(),
messageId,
content,
});
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await dispatch({
action: "deleteMessage",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await dispatch({
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
roomId: resolveRoomId(),
messageId,
});
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await dispatch({
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
});
}
if (action === "channel-info") {
return await dispatch({
action: "channelInfo",
roomId: resolveRoomId(),
});
}
if (action === "permissions") {
const operation = (
readStringParam(params, "operation") ??
readStringParam(params, "mode") ??
"verification-list"
)
.trim()
.toLowerCase();
const operationToAction: Record<string, string> = {
"encryption-status": "encryptionStatus",
"verification-status": "verificationStatus",
"verification-bootstrap": "verificationBootstrap",
"verification-recovery-key": "verificationRecoveryKey",
"verification-backup-status": "verificationBackupStatus",
"verification-backup-restore": "verificationBackupRestore",
"verification-list": "verificationList",
"verification-request": "verificationRequest",
"verification-accept": "verificationAccept",
"verification-cancel": "verificationCancel",
"verification-start": "verificationStart",
"verification-generate-qr": "verificationGenerateQr",
"verification-scan-qr": "verificationScanQr",
"verification-sas": "verificationSas",
"verification-confirm": "verificationConfirm",
"verification-mismatch": "verificationMismatch",
"verification-confirm-qr": "verificationConfirmQr",
};
const resolvedAction = operationToAction[operation];
if (!resolvedAction) {
throw new Error(
`Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys(
operationToAction,
).join(", ")}`,
);
}
return await dispatch({
...params,
action: resolvedAction,
});
}
throw new Error(`Action ${action} is not supported for provider matrix-js.`);
},
};

View File

@@ -1,447 +0,0 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { matrixPlugin } from "./channel.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix directory", () => {
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number): never => {
throw new Error(`exit ${code}`);
}),
};
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {
"matrix-js": {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.directory).toBeTruthy();
expect(matrixPlugin.directory?.listPeers).toBeTruthy();
expect(matrixPlugin.directory?.listGroups).toBeTruthy();
await expect(
matrixPlugin.directory!.listPeers!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);
await expect(
matrixPlugin.directory!.listGroups!({
cfg,
accountId: undefined,
query: undefined,
limit: undefined,
runtime: runtimeEnv,
}),
).resolves.toEqual(
expect.arrayContaining([
{ kind: "group", id: "room:!room1:example.org" },
{ kind: "group", id: "#alias:example.org" },
]),
);
});
it("resolves replyToMode from account config", () => {
const cfg = {
channels: {
"matrix-js": {
replyToMode: "off",
accounts: {
Assistant: {
replyToMode: "all",
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "assistant",
chatType: "direct",
}),
).toBe("all");
expect(
matrixPlugin.threading?.resolveReplyToMode?.({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("only exposes real Matrix thread ids in tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: undefined,
hasRepliedRef: { value: false },
});
expect(
matrixPlugin.threading?.buildToolContext?.({
context: {
To: "room:!room:example.org",
ReplyToId: "$reply",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: true },
}),
).toEqual({
currentChannelId: "room:!room:example.org",
currentThreadTs: "$thread",
hasRepliedRef: { value: true },
});
});
it("exposes Matrix direct user id in dm tool context", () => {
expect(
matrixPlugin.threading?.buildToolContext?.({
context: {
From: "matrix:@alice:example.org",
To: "room:!dm:example.org",
ChatType: "direct",
MessageThreadId: "$thread",
},
hasRepliedRef: { value: false },
}),
).toEqual({
currentChannelId: "room:!dm:example.org",
currentThreadTs: "$thread",
currentDirectUserId: "@alice:example.org",
hasRepliedRef: { value: false },
});
});
it("resolves group mention policy from account config", () => {
const cfg = {
channels: {
"matrix-js": {
groups: {
"!room:example.org": { requireMention: true },
},
accounts: {
Assistant: {
groups: {
"!room:example.org": { requireMention: false },
},
},
},
},
},
} as unknown as CoreConfig;
expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe(
true,
);
expect(
matrixPlugin.groups!.resolveRequireMention!({
cfg,
accountId: "assistant",
groupId: "!room:example.org",
}),
).toBe(false);
});
it("writes matrix-js non-default account credentials under channels.matrix-js.accounts", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://default.example.org",
accessToken: "default-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "ops",
input: {
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
accessToken: "default-token",
homeserver: "https://default.example.org",
});
expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "ops-token",
});
});
it("writes default matrix-js account credentials under channels.matrix-js.accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
enabled: true,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "bot-token",
});
});
it("migrates legacy top-level matrix-js credentials into accounts.default", () => {
const cfg = {
channels: {
"matrix-js": {
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
register: false,
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.encryption).toBeUndefined();
expect((updated.channels?.["matrix-js"] as Record<string, unknown>)?.register).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({
name: "bot-default",
homeserver: "https://legacy.example.org",
userId: "@legacy:example.org",
accessToken: "legacy-token",
deviceName: "Legacy Device",
encryption: true,
groupPolicy: "allowlist",
groups: {
"!legacy-room:example.org": { allow: true },
},
});
});
it("merges top-level object defaults into accounts.default during migration", () => {
const cfg = {
channels: {
"matrix-js": {
dm: {
policy: "allowlist",
allowFrom: ["@legacy:example.org"],
},
accounts: {
default: {
dm: {
policy: "pairing",
},
},
},
},
},
} as unknown as CoreConfig;
const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg);
expect(updated.channels?.["matrix-js"]?.dm).toBeUndefined();
expect(updated.channels?.["matrix-js"]?.accounts?.default?.dm).toMatchObject({
policy: "pairing",
allowFrom: ["@legacy:example.org"],
});
});
it("requires account-scoped env vars when --use-env is set for non-default accounts", () => {
const envKeys = [
"MATRIX_OPS_HOMESERVER",
"MATRIX_OPS_USER_ID",
"MATRIX_OPS_ACCESS_TOKEN",
"MATRIX_OPS_PASSWORD",
] as const;
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record<
(typeof envKeys)[number],
string | undefined
>;
for (const key of envKeys) {
delete process.env[key];
}
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBe(
'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).',
);
} finally {
for (const key of envKeys) {
if (previousEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = previousEnv[key];
}
}
}
});
it("accepts --use-env for non-default account when scoped env vars are present", () => {
const envKeys = {
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
};
process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token";
try {
const error = matrixPlugin.setup!.validateInput?.({
cfg: {} as CoreConfig,
accountId: "ops",
input: { useEnv: true },
});
expect(error).toBeNull();
} finally {
for (const [key, value] of Object.entries(envKeys)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
it("resolves account id from input name when explicit account id is missing", () => {
const accountId = matrixPlugin.setup!.resolveAccountId?.({
cfg: {} as CoreConfig,
accountId: undefined,
input: { name: "Main Bot" },
});
expect(accountId).toBe("main-bot");
});
it("resolves binding account id from agent id when omitted", () => {
const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({
cfg: {} as CoreConfig,
agentId: "Ops",
accountId: undefined,
});
expect(accountId).toBe("ops");
});
it("clears stale access token when switching an account to password auth", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "old-token",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "new-password",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBe("new-password");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBeUndefined();
});
it("clears stale password when switching an account to token auth", () => {
const cfg = {
channels: {
"matrix-js": {
accounts: {
default: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "old-password",
},
},
},
},
} as unknown as CoreConfig;
const updated = matrixPlugin.setup!.applyAccountConfig({
cfg,
accountId: "default",
input: {
homeserver: "https://matrix.example.org",
accessToken: "new-token",
},
}) as CoreConfig;
expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBe("new-token");
expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined();
});
});

View File

@@ -1,515 +0,0 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelSetupInput,
type ChannelPlugin,
} from "openclaw/plugin-sdk/matrix-js";
import { matrixMessageActions } from "./actions.js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { MatrixConfigSchema } from "./config-schema.js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import {
resolveMatrixGroupRequireMention,
resolveMatrixGroupToolPolicy,
} from "./group-mentions.js";
import {
listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixAuth,
resolveScopedMatrixEnvConfig,
} from "./matrix/client.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
import { probeMatrix } from "./matrix/probe.js";
import { isSupportedMatrixAvatarSource } from "./matrix/profile.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const meta = {
id: "matrix-js",
label: "Matrix-js",
selectionLabel: "Matrix-js (plugin)",
docsPath: "/channels/matrix-js",
docsLabel: "matrix-js",
blurb: "open protocol; configure a homeserver + access token.",
order: 70,
quickstartAllowFrom: true,
};
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim();
if (!normalized) {
return undefined;
}
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim();
}
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
return stripped || undefined;
}
function resolveMatrixDirectUserId(params: {
from?: string;
to?: string;
chatType?: string;
}): string | undefined {
if (params.chatType !== "direct") {
return undefined;
}
const from = params.from?.trim();
const to = params.to?.trim();
if (!from || !to || !/^room:/i.test(to)) {
return undefined;
}
const normalized = from
.replace(/^matrix:/i, "")
.replace(/^user:/i, "")
.trim();
return normalized.startsWith("@") ? normalized : undefined;
}
function resolveAvatarInput(input: ChannelSetupInput): string | undefined {
const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl;
const trimmed = avatarUrl?.trim();
return trimmed ? trimmed : undefined;
}
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix-js",
meta,
onboarding: matrixOnboardingAdapter,
pairing: {
idLabel: "matrixUserId",
normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""),
notifyApproval: async ({ id }) => {
await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,
reactions: true,
threads: true,
media: true,
},
reload: { configPrefixes: ["channels.matrix-js"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "matrix-js",
accountId,
clearBaseFields: [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"avatarUrl",
"initialSyncLimit",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
}),
resolveAllowFrom: ({ cfg, accountId }) => {
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => {
const accountId = account.accountId;
const prefix =
accountId && accountId !== "default"
? `channels.matrix-js.accounts.${accountId}.dm`
: "channels.matrix-js.dm";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${prefix}.policy`,
allowFromPath: `${prefix}.allowFrom`,
approveHint: formatPairingApproveHint("matrix-js"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: (cfg as CoreConfig).channels?.["matrix-js"] !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
if (groupPolicy !== "open") {
return [];
}
return [
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix-js.groupPolicy="allowlist" + channels.matrix-js.groups (and optionally channels.matrix-js.groupAllowFrom) to restrict rooms.',
];
},
},
groups: {
resolveRequireMention: resolveMatrixGroupRequireMention,
resolveToolPolicy: resolveMatrixGroupToolPolicy,
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: currentTarget?.trim() || undefined,
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
currentDirectUserId: resolveMatrixDirectUserId({
from: context.From,
to: context.To,
chatType: context.ChatType,
}),
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
return true;
}
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = new Set<string>();
for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, ""));
}
}
return Array.from(ids)
.map((raw) => raw.trim())
.filter(Boolean)
.map((raw) => {
const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) {
return `user:${cleaned}`;
}
return cleaned;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => {
const raw = id.startsWith("user:") ? id.slice("user:".length) : id;
const incomplete = !raw.startsWith("@") || !raw.includes(":");
return {
kind: "user",
id,
...(incomplete ? { name: "incomplete id; expected @user:server" } : {}),
};
});
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => {
const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
return raw;
}
if (raw.startsWith("!")) {
return `room:${raw}`;
}
return raw;
})
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return ids;
},
listPeersLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
},
resolver: {
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
resolveMatrixTargets({ cfg, inputs, kind, runtime }),
},
actions: matrixMessageActions,
setup: {
resolveAccountId: ({ accountId, input }) =>
normalizeAccountId(accountId?.trim() || input?.name?.trim()),
resolveBindingAccountId: ({ agentId, accountId }) =>
normalizeAccountId(accountId?.trim() || agentId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "matrix-js",
accountId,
name,
alwaysUseAccounts: true,
}),
validateInput: ({ accountId, input }) => {
const avatarUrl = resolveAvatarInput(input);
if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) {
return "Matrix avatar URL must be an mxc:// URI or an http(s) URL";
}
if (input.useEnv) {
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) {
const keys = getMatrixScopedEnvVarNames(accountId);
return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`;
}
return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) {
return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig);
const namedConfig = applyAccountNameToChannelSection({
cfg: migratedConfig,
channelKey: "matrix-js",
accountId,
name: input.name,
alwaysUseAccounts: true,
});
const next = migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "matrix-js",
});
if (input.useEnv) {
return setAccountEnabledInConfigSection({
cfg: next as CoreConfig,
sectionKey: "matrix-js",
accountId,
enabled: true,
}) as CoreConfig;
}
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
return updateMatrixAccountConfig(next as CoreConfig, accountId, {
homeserver: input.homeserver?.trim(),
userId: password && !userId ? null : userId,
accessToken: accessToken || (password ? null : undefined),
password: password || (accessToken ? null : undefined),
deviceName: input.deviceName?.trim(),
avatarUrl: resolveAvatarInput(input),
initialSyncLimit: input.initialSyncLimit,
});
},
},
outbound: matrixOutbound,
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "matrix-js",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs, cfg }) => {
try {
const auth = await resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
userId: auth.userId,
timeoutMs,
});
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
elapsedMs: 0,
};
}
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.homeserver,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastProbeAt: runtime?.lastProbeAt ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.homeserver,
});
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
// Serialize startup: wait for any previous startup to complete import phase.
// This works around a race condition with concurrent dynamic imports.
//
// INVARIANT: The import() below cannot hang because:
// 1. It only loads local ESM modules with no circular awaits
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
// 3. The lock only serializes the import phase, not the provider startup
const previousLock = matrixStartupLock;
let releaseLock: () => void = () => {};
matrixStartupLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
await previousLock;
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
// Wrap in try/finally to ensure lock is released even if import fails.
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
mediaMaxMb: account.config.mediaMaxMb,
initialSyncLimit: account.config.initialSyncLimit,
replyToMode: account.config.replyToMode,
accountId: account.accountId,
});
},
},
};

View File

@@ -1,139 +0,0 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js";
type LegacyAccountField =
| "name"
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName"
| "initialSyncLimit"
| "encryption"
| "allowlistOnly"
| "groupPolicy"
| "groupAllowFrom"
| "replyToMode"
| "threadReplies"
| "textChunkLimit"
| "chunkMode"
| "responsePrefix"
| "threadBindings"
| "startupVerification"
| "startupVerificationCooldownHours"
| "mediaMaxMb"
| "autoJoin"
| "autoJoinAllowlist"
| "dm"
| "groups"
| "rooms"
| "actions";
const LEGACY_ACCOUNT_FIELDS: ReadonlyArray<LegacyAccountField> = [
"name",
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
"initialSyncLimit",
"encryption",
"allowlistOnly",
"groupPolicy",
"groupAllowFrom",
"replyToMode",
"threadReplies",
"textChunkLimit",
"chunkMode",
"responsePrefix",
"threadBindings",
"startupVerification",
"startupVerificationCooldownHours",
"mediaMaxMb",
"autoJoin",
"autoJoinAllowlist",
"dm",
"groups",
"rooms",
"actions",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function mergeLegacyFieldIntoDefault(
current: MatrixAccountConfig[LegacyAccountField] | undefined,
legacy: MatrixAccountConfig[LegacyAccountField],
): MatrixAccountConfig[LegacyAccountField] {
if (current === undefined) {
return legacy;
}
if (isRecord(current) && isRecord(legacy)) {
return {
...legacy,
...current,
} as MatrixAccountConfig[LegacyAccountField];
}
return current;
}
function clearLegacyOnlyFields(nextMatrix: MatrixConfig): void {
// Legacy matrix-bot-sdk onboarding toggle; not used by matrix-js config.
delete (nextMatrix as Record<string, unknown>).register;
}
export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig {
const matrix = cfg.channels?.["matrix-js"];
if (!matrix) {
return cfg;
}
const defaultAccount = {
...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}),
} as MatrixAccountConfig;
let changed = false;
for (const field of LEGACY_ACCOUNT_FIELDS) {
const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined;
if (legacyValue === undefined) {
continue;
}
(
defaultAccount as Record<
LegacyAccountField,
MatrixAccountConfig[LegacyAccountField] | undefined
>
)[field] = mergeLegacyFieldIntoDefault(defaultAccount[field], legacyValue);
changed = true;
}
const registerPresent = (matrix as Record<string, unknown>).register !== undefined;
if (registerPresent) {
changed = true;
}
if (!changed) {
return cfg;
}
const nextMatrix = { ...matrix } as MatrixConfig;
for (const field of LEGACY_ACCOUNT_FIELDS) {
delete nextMatrix[field];
}
clearLegacyOnlyFields(nextMatrix);
nextMatrix.accounts = {
...matrix.accounts,
[DEFAULT_ACCOUNT_ID]: defaultAccount,
};
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": nextMatrix,
},
};
}

View File

@@ -1,84 +0,0 @@
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix-js";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const matrixActionSchema = z
.object({
reactions: z.boolean().optional(),
messages: z.boolean().optional(),
pins: z.boolean().optional(),
memberInfo: z.boolean().optional(),
channelInfo: z.boolean().optional(),
verification: z.boolean().optional(),
})
.optional();
const matrixThreadBindingsSchema = z
.object({
enabled: z.boolean().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSubagentSessions: z.boolean().optional(),
spawnAcpSessions: z.boolean().optional(),
})
.optional();
const matrixDmSchema = z
.object({
enabled: z.boolean().optional(),
policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
})
.optional();
const matrixRoomSchema = z
.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
autoReply: z.boolean().optional(),
users: z.array(allowFromEntry).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.optional();
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
avatarUrl: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
textChunkLimit: z.number().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
responsePrefix: z.string().optional(),
ackReaction: z.string().optional(),
ackReactionScope: z
.enum(["group-mentions", "group-all", "direct", "all", "none", "off"])
.optional(),
reactionNotifications: z.enum(["off", "own"]).optional(),
threadBindings: matrixThreadBindingsSchema,
startupVerification: z.enum(["off", "if-unverified"]).optional(),
startupVerificationCooldownHours: z.number().optional(),
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -1,74 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAuth } from "./matrix/client.js";
vi.mock("./matrix/client.js", () => ({
resolveMatrixAuth: vi.fn(),
}));
describe("matrix directory live", () => {
const cfg = { channels: { "matrix-js": {} } };
beforeEach(() => {
vi.mocked(resolveMatrixAuth).mockReset();
vi.mocked(resolveMatrixAuth).mockResolvedValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "test-token",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: [] }),
text: async () => "",
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("passes accountId to peer directory auth resolution", async () => {
await listMatrixDirectoryPeersLive({
cfg,
accountId: "assistant",
query: "alice",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("passes accountId to group directory auth resolution", async () => {
await listMatrixDirectoryGroupsLive({
cfg,
accountId: "assistant",
query: "!room:example.org",
limit: 10,
});
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
});
it("returns no peer results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryPeersLive({
cfg,
query: " ",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
it("returns no group results for empty query without resolving auth", async () => {
const result = await listMatrixDirectoryGroupsLive({
cfg,
query: "",
});
expect(result).toEqual([]);
expect(resolveMatrixAuth).not.toHaveBeenCalled();
});
});

View File

@@ -1,208 +0,0 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAuth } from "./matrix/client.js";
type MatrixUserResult = {
user_id?: string;
display_name?: string;
};
type MatrixUserDirectoryResponse = {
results?: MatrixUserResult[];
};
type MatrixJoinedRoomsResponse = {
joined_rooms?: string[];
};
type MatrixRoomNameState = {
name?: string;
};
type MatrixAliasLookup = {
room_id?: string;
};
type MatrixDirectoryLiveParams = {
cfg: unknown;
accountId?: string | null;
query?: string | null;
limit?: number | null;
};
type MatrixResolvedAuth = Awaited<ReturnType<typeof resolveMatrixAuth>>;
async function fetchMatrixJson<T>(params: {
homeserver: string;
path: string;
accessToken: string;
method?: "GET" | "POST";
body?: unknown;
}): Promise<T> {
const res = await fetch(`${params.homeserver}${params.path}`, {
method: params.method ?? "GET",
headers: {
Authorization: `Bearer ${params.accessToken}`,
"Content-Type": "application/json",
},
body: params.body ? JSON.stringify(params.body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`);
}
return (await res.json()) as T;
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function resolveMatrixDirectoryLimit(limit?: number | null): number {
return typeof limit === "number" && limit > 0 ? limit : 20;
}
async function resolveMatrixDirectoryContext(
params: MatrixDirectoryLiveParams,
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
const query = normalizeQuery(params.query);
if (!query) {
return null;
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
return { query, auth };
}
function createGroupDirectoryEntry(params: {
id: string;
name: string;
handle?: string;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name,
handle: params.handle,
} satisfies ChannelDirectoryEntry;
}
export async function listMatrixDirectoryPeersLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/user_directory/search",
method: "POST",
body: {
search_term: query,
limit: resolveMatrixDirectoryLimit(params.limit),
},
});
const results = res.results ?? [];
return results
.map((entry) => {
const userId = entry.user_id?.trim();
if (!userId) {
return null;
}
return {
kind: "user",
id: userId,
name: entry.display_name?.trim() || undefined,
handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined,
raw: entry,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
}
async function resolveMatrixRoomAlias(
homeserver: string,
accessToken: string,
alias: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixAliasLookup>({
homeserver,
accessToken,
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
});
return res.room_id?.trim() || null;
} catch {
return null;
}
}
async function fetchMatrixRoomName(
homeserver: string,
accessToken: string,
roomId: string,
): Promise<string | null> {
try {
const res = await fetchMatrixJson<MatrixRoomNameState>({
homeserver,
accessToken,
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
});
return res.name?.trim() || null;
} catch {
return null;
}
}
export async function listMatrixDirectoryGroupsLive(
params: MatrixDirectoryLiveParams,
): Promise<ChannelDirectoryEntry[]> {
const context = await resolveMatrixDirectoryContext(params);
if (!context) {
return [];
}
const { query, auth } = context;
const limit = resolveMatrixDirectoryLimit(params.limit);
if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) {
return [];
}
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
}
if (query.startsWith("!")) {
return [createGroupDirectoryEntry({ id: query, name: query })];
}
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
path: "/_matrix/client/v3/joined_rooms",
});
const rooms = joined.joined_rooms ?? [];
const results: ChannelDirectoryEntry[] = [];
for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) {
continue;
}
if (!name.toLowerCase().includes(query)) {
continue;
}
results.push({
kind: "group",
id: roomId,
name,
handle: `#${name}`,
});
if (results.length >= limit) {
break;
}
}
return results;
}

View File

@@ -1,52 +0,0 @@
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
import type { CoreConfig } from "./types.js";
function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string {
return value.toLowerCase().startsWith(prefix.toLowerCase())
? value.slice(prefix.length).trim()
: value;
}
function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) {
const rawGroupId = params.groupId?.trim() ?? "";
let roomId = rawGroupId;
roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:");
roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:");
const groupChannel = params.groupChannel?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
return resolveMatrixRoomConfig({
rooms: matrixConfig.groups ?? matrixConfig.rooms,
roomId,
aliases,
name: groupChannel || undefined,
}).config;
}
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
const resolved = resolveMatrixRoomConfigForGroup(params);
if (resolved) {
if (resolved.autoReply === true) {
return false;
}
if (resolved.autoReply === false) {
return true;
}
if (typeof resolved.requireMention === "boolean") {
return resolved.requireMention;
}
}
return true;
}
export function resolveMatrixGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const resolved = resolveMatrixRoomConfigForGroup(params);
return resolved?.tools;
}

View File

@@ -1,82 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
];
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
});

View File

@@ -1,127 +0,0 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountsMap,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
/** Merge account config with top-level defaults, preserving nested objects. */
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
const merged = { ...base, ...account };
// Deep-merge known nested objects so partial overrides inherit base fields
for (const key of ["dm", "actions"] as const) {
const b = base[key];
const o = account[key];
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
(merged as Record<string, unknown>)[key] = { ...b, ...o };
}
}
// Don't propagate the accounts map into the merged per-account config
delete (merged as Record<string, unknown>).accounts;
return merged;
}
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
homeserver?: string;
userId?: string;
config: MatrixConfig;
};
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = resolveMatrixAccountsMap(cfg);
if (Object.keys(accounts).length === 0) {
return [];
}
// Normalize and de-duplicate keys so listing and resolution use the same semantics
return [
...new Set(
Object.keys(accounts)
.filter(Boolean)
.map((id) => normalizeAccountId(id)),
),
];
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Fall back to default if no accounts configured (legacy top-level config)
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
const ids = listMatrixAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
return findMatrixAccountConfig(cfg, accountId);
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env, accountId);
const hasStored =
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,
name: base.name?.trim() || undefined,
configured,
homeserver: resolved.homeserver || undefined,
userId: resolved.userId || undefined,
config: base,
};
}
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const accountConfig = resolveAccountConfig(params.cfg, accountId);
if (!accountConfig) {
return matrixBase;
}
// Merge account-specific config with top-level defaults so settings like
// groupPolicy and blockStreaming inherit when not overridden.
return mergeAccountConfig(matrixBase, accountConfig);
}
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
return listMatrixAccountIds(cfg)
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -1,36 +0,0 @@
export type {
MatrixActionClientOpts,
MatrixMessageSummary,
MatrixReactionSummary,
} from "./actions/types.js";
export {
sendMatrixMessage,
editMatrixMessage,
deleteMatrixMessage,
readMatrixMessages,
} from "./actions/messages.js";
export { voteMatrixPoll } from "./actions/polls.js";
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
export { updateMatrixOwnProfile } from "./actions/profile.js";
export {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixVerificationSas,
listMatrixVerifications,
mismatchMatrixVerificationSas,
requestMatrixVerification,
restoreMatrixRoomKeyBackup,
scanMatrixVerificationQr,
startMatrixVerification,
verifyMatrixRecoveryKey,
} from "./actions/verification.js";
export { reactMatrixMessage } from "./send.js";

View File

@@ -1,70 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId: opts.accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();
return { client, stopOnDone: true };
}
export type MatrixActionClientStopMode = "stop" | "persist";
export async function stopActionClient(
resolved: MatrixActionClient,
mode: MatrixActionClientStopMode = "stop",
): Promise<void> {
if (!resolved.stopOnDone) {
return;
}
if (mode === "persist") {
await resolved.client.stopAndPersist();
return;
}
resolved.client.stop();
}
export async function withResolvedActionClient<T>(
opts: MatrixActionClientOpts,
run: (client: MatrixActionClient["client"]) => Promise<T>,
mode: MatrixActionClientStopMode = "stop",
): Promise<T> {
const resolved = await resolveActionClient(opts);
try {
return await run(resolved.client);
} finally {
await stopActionClient(resolved, mode);
}
}

View File

@@ -1,15 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixActionLimit } from "./limits.js";
describe("resolveMatrixActionLimit", () => {
it("uses fallback for non-finite values", () => {
expect(resolveMatrixActionLimit(undefined, 20)).toBe(20);
expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20);
});
it("normalizes finite numbers to positive integers", () => {
expect(resolveMatrixActionLimit(7.9, 20)).toBe(7);
expect(resolveMatrixActionLimit(0, 20)).toBe(1);
expect(resolveMatrixActionLimit(-3, 20)).toBe(1);
});
});

View File

@@ -1,6 +0,0 @@
export function resolveMatrixActionLimit(raw: unknown, fallback: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return fallback;
}
return Math.max(1, Math.floor(raw));
}

View File

@@ -1,112 +0,0 @@
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
export async function sendMatrixMessage(
to: string,
content: string,
opts: MatrixActionClientOpts & {
mediaUrl?: string;
replyToId?: string;
threadId?: string;
} = {},
) {
return await sendMessageMatrix(to, content, {
mediaUrl: opts.mediaUrl,
replyToId: opts.replyToId,
threadId: opts.threadId,
accountId: opts.accountId,
client: opts.client,
timeoutMs: opts.timeoutMs,
});
}
export async function editMatrixMessage(
roomId: string,
messageId: string,
content: string,
opts: MatrixActionClientOpts = {},
) {
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
});
}
export async function deleteMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { reason?: string } = {},
) {
await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
});
}
export async function readMatrixMessages(
roomId: string,
opts: MatrixActionClientOpts & {
limit?: number;
before?: string;
after?: string;
} = {},
): Promise<{
messages: MatrixMessageSummary[];
nextBatch?: string | null;
prevBatch?: string | null;
}> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit = resolveMatrixActionLimit(opts.limit, 20);
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
{
dir,
limit,
from: token,
},
)) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
});
}

View File

@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js";
function createPinsClient(seedPinned: string[], knownBodies: Record<string, string> = {}) {
let pinned = [...seedPinned];
const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] }));
const sendStateEvent = vi.fn(
async (_roomId: string, _type: string, _key: string, payload: any) => {
pinned = [...payload.pinned];
},
);
const getEvent = vi.fn(async (_roomId: string, eventId: string) => {
const body = knownBodies[eventId];
if (!body) {
throw new Error("missing");
}
return {
event_id: eventId,
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: 123,
content: { msgtype: "m.text", body },
};
});
return {
client: {
getRoomStateEvent,
sendStateEvent,
getEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
getPinned: () => pinned,
sendStateEvent,
};
}
describe("matrix pins actions", () => {
it("pins a message once even when asked twice", async () => {
const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]);
const first = await pinMatrixMessage("!room:example.org", "$b", { client });
const second = await pinMatrixMessage("!room:example.org", "$b", { client });
expect(first.pinned).toEqual(["$a", "$b"]);
expect(second.pinned).toEqual(["$a", "$b"]);
expect(getPinned()).toEqual(["$a", "$b"]);
expect(sendStateEvent).toHaveBeenCalledTimes(2);
});
it("unpinds only the selected message id", async () => {
const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]);
const result = await unpinMatrixMessage("!room:example.org", "$b", { client });
expect(result.pinned).toEqual(["$a", "$c"]);
expect(getPinned()).toEqual(["$a", "$c"]);
});
it("lists pinned ids and summarizes only resolvable events", async () => {
const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" });
const result = await listMatrixPins("!room:example.org", { client });
expect(result.pinned).toEqual(["$a", "$missing"]);
expect(result.events).toEqual([
expect.objectContaining({
eventId: "$a",
body: "hello",
}),
]);
});
});

View File

@@ -1,79 +0,0 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { fetchEventSummary, readPinnedEvents } from "./summary.js";
import {
EventType,
type MatrixActionClientOpts,
type MatrixActionClient,
type MatrixMessageSummary,
type RoomPinnedEventsEventContent,
} from "./types.js";
type ActionClient = MatrixActionClient["client"];
async function withResolvedPinRoom<T>(
roomId: string,
opts: MatrixActionClientOpts,
run: (client: ActionClient, resolvedRoom: string) => Promise<T>,
): Promise<T> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
return await run(client, resolvedRoom);
});
}
async function updateMatrixPins(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts,
update: (current: string[]) => string[],
): Promise<{ pinned: string[] }> {
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
const current = await readPinnedEvents(client, resolvedRoom);
const next = update(current);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
});
}
export async function pinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.includes(messageId) ? current : [...current, messageId],
);
}
export async function unpinMatrixMessage(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[] }> {
return await updateMatrixPins(roomId, messageId, opts, (current) =>
current.filter((id) => id !== messageId),
);
}
export async function listMatrixPins(
roomId: string,
opts: MatrixActionClientOpts = {},
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
const pinned = await readPinnedEvents(client, resolvedRoom);
const events = (
await Promise.all(
pinned.map(async (eventId) => {
try {
return await fetchEventSummary(client, resolvedRoom, eventId);
} catch {
return null;
}
}),
)
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
});
}

View File

@@ -1,135 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
function createReactionsClient(params: {
chunk: Array<{
event_id?: string;
sender?: string;
key?: string;
}>;
userId?: string | null;
}) {
const doRequest = vi.fn(async (_method: string, _path: string, _query: any) => ({
chunk: params.chunk.map((item) => ({
event_id: item.event_id ?? "",
sender: item.sender ?? "",
content: item.key
? {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$target",
key: item.key,
},
}
: {},
})),
}));
const getUserId = vi.fn(async () => params.userId ?? null);
const redactEvent = vi.fn(async () => undefined);
return {
client: {
doRequest,
getUserId,
redactEvent,
stop: vi.fn(),
} as unknown as MatrixClient,
doRequest,
redactEvent,
};
}
describe("matrix reaction actions", () => {
it("aggregates reactions by key and unique sender", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@alice:example.org", key: "👍" },
{ event_id: "$2", sender: "@bob:example.org", key: "👍" },
{ event_id: "$3", sender: "@alice:example.org", key: "👎" },
{ event_id: "$4", sender: "@bot:example.org" },
],
userId: "@bot:example.org",
});
const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 });
expect(doRequest).toHaveBeenCalledWith(
"GET",
expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"),
expect.objectContaining({ limit: 2 }),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "👍",
count: 2,
users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]),
}),
expect.objectContaining({
key: "👎",
count: 1,
users: ["@alice:example.org"],
}),
]),
);
});
it("removes only current-user reactions matching emoji filter", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [
{ event_id: "$1", sender: "@me:example.org", key: "👍" },
{ event_id: "$2", sender: "@me:example.org", key: "👎" },
{ event_id: "$3", sender: "@other:example.org", key: "👍" },
],
userId: "@me:example.org",
});
const result = await removeMatrixReactions("!room:example.org", "$msg", {
client,
emoji: "👍",
});
expect(result).toEqual({ removed: 1 });
expect(redactEvent).toHaveBeenCalledTimes(1);
expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1");
});
it("returns removed=0 when current user id is unavailable", async () => {
const { client, redactEvent } = createReactionsClient({
chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }],
userId: null,
});
const result = await removeMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual({ removed: 0 });
expect(redactEvent).not.toHaveBeenCalled();
});
it("returns an empty list when the relations response is malformed", async () => {
const doRequest = vi.fn(async () => ({ chunk: null }));
const client = {
doRequest,
getUserId: vi.fn(async () => "@me:example.org"),
redactEvent: vi.fn(async () => undefined),
stop: vi.fn(),
} as unknown as MatrixClient;
const result = await listMatrixReactions("!room:example.org", "$msg", { client });
expect(result).toEqual([]);
});
it("rejects blank message ids before querying Matrix relations", async () => {
const { client, doRequest } = createReactionsClient({
chunk: [],
userId: "@me:example.org",
});
await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow(
"messageId",
);
expect(doRequest).not.toHaveBeenCalled();
});
});

View File

@@ -1,62 +0,0 @@
import {
buildMatrixReactionRelationsPath,
selectOwnMatrixReactionEventIds,
summarizeMatrixReactionEvents,
} from "../reaction-common.js";
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import {
type MatrixActionClientOpts,
type MatrixRawEvent,
type MatrixReactionSummary,
} from "./types.js";
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
async function listMatrixReactionEvents(
client: ActionClient,
roomId: string,
messageId: string,
limit: number,
): Promise<MatrixRawEvent[]> {
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
dir: "b",
limit,
})) as { chunk?: MatrixRawEvent[] };
return Array.isArray(res.chunk) ? res.chunk : [];
}
export async function listMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { limit?: number } = {},
): Promise<MatrixReactionSummary[]> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const limit = resolveMatrixActionLimit(opts.limit, 100);
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
return summarizeMatrixReactionEvents(chunk);
});
}
export async function removeMatrixReactions(
roomId: string,
messageId: string,
opts: MatrixActionClientOpts & { emoji?: string } = {},
): Promise<{ removed: number }> {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
const userId = await client.getUserId();
if (!userId) {
return { removed: 0 };
}
const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji);
if (toRemove.length === 0) {
return { removed: 0 };
}
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
});
}

View File

@@ -1,72 +0,0 @@
import { resolveMatrixRoomId } from "../send.js";
import { withResolvedActionClient } from "./client.js";
import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
) {
return await withResolvedActionClient(opts, async (client) => {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getUserProfile(userId);
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
});
}
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
return await withResolvedActionClient(opts, async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch {
// ignore
}
return {
roomId: resolvedRoom,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
});
}

View File

@@ -1,75 +0,0 @@
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
type RoomPinnedEventsEventContent,
} from "./types.js";
export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
if (relates) {
if ("rel_type" in relates) {
relType = relates.rel_type;
eventId = relates.event_id;
} else if ("m.in_reply_to" in relates) {
eventId = relates["m.in_reply_to"]?.event_id;
}
}
const relatesTo =
relType || eventId
? {
relType,
eventId,
}
: undefined;
return {
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.origin_server_ts,
relatesTo,
};
}
export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
throw err;
}
}
export async function fetchEventSummary(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
try {
const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent;
if (raw.unsigned?.redacted_because) {
return null;
}
return summarizeMatrixRawEvent(raw);
} catch {
// Event not found, redacted, or inaccessible - return null
return null;
}
}

View File

@@ -1,69 +0,0 @@
import {
MATRIX_ANNOTATION_RELATION_TYPE,
MATRIX_REACTION_EVENT_TYPE,
type MatrixReactionEventContent,
type MatrixReactionSummary,
} from "../reaction-common.js";
import type { MatrixClient, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const MsgType = {
Text: "m.text",
} as const;
export const RelationType = {
Replace: "m.replace",
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
} as const;
export const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: MATRIX_REACTION_EVENT_TYPE,
} as const;
export type RoomMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
export type ReactionEventContent = MatrixReactionEventContent;
export type RoomPinnedEventsEventContent = {
pinned: string[];
};
export type RoomTopicEventContent = {
topic?: string;
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
export type MatrixMessageSummary = {
eventId?: string;
sender?: string;
body?: string;
msgtype?: string;
timestamp?: number;
relatesTo?: {
relType?: string;
eventId?: string;
key?: string;
};
};
export type MatrixActionClient = {
client: MatrixClient;
stopOnDone: boolean;
};

View File

@@ -1,26 +0,0 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MatrixClient } from "./sdk.js";
const activeClients = new Map<string, MatrixClient>();
function resolveAccountKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized || DEFAULT_ACCOUNT_ID;
}
export function setActiveMatrixClient(
client: MatrixClient | null,
accountId?: string | null,
): void {
const key = resolveAccountKey(accountId);
if (!client) {
activeClients.delete(key);
return;
}
activeClients.set(key, client);
}
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
const key = resolveAccountKey(accountId);
return activeClients.get(key) ?? null;
}

View File

@@ -1,39 +0,0 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
};
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
userId: opts.auth.userId,
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off requests.
}
}
await client.start();
return client;
}

View File

@@ -1,314 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: saveMatrixCredentialsMock,
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved).toEqual({
homeserver: "https://cfg.example.org",
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
it("uses env when config is missing", () => {
const cfg = {} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://env.example.org",
MATRIX_USER_ID: "@env:example.org",
MATRIX_ACCESS_TOKEN: "env-token",
MATRIX_PASSWORD: "env-pass",
MATRIX_DEVICE_ID: "ENVDEVICE",
MATRIX_DEVICE_NAME: "EnvDevice",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfig(cfg, env);
expect(resolved.homeserver).toBe("https://env.example.org");
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("uses account-scoped env vars for non-default accounts before global env", () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://base.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://global.example.org",
MATRIX_ACCESS_TOKEN: "global-token",
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
MATRIX_OPS_DEVICE_NAME: "Ops Device",
} as NodeJS.ProcessEnv;
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
expect(resolved.homeserver).toBe("https://ops.example.org");
expect(resolved.accessToken).toBe("ops-token");
expect(resolved.deviceName).toBe("Ops Device");
});
});
describe("resolveMatrixAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("surfaces password login errors when account credentials are invalid", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
await expect(
resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Invalid username or password");
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth.deviceId).toBe("DEVICE123");
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
expect.any(Object),
undefined,
);
});
it("resolves missing whoami identity fields for token auth", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
deviceId: "DEVICE123",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
});
});

View File

@@ -1,17 +0,0 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveMatrixConfig,
resolveMatrixConfigForAccount,
resolveScopedMatrixEnvConfig,
resolveMatrixAuth,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export {
resolveSharedMatrixClient,
waitForMatrixSync,
stopSharedClient,
stopSharedClientForAccount,
} from "./client/shared.js";

View File

@@ -1,362 +0,0 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined,
password: clean(env.MATRIX_PASSWORD) || undefined,
deviceId: clean(env.MATRIX_DEVICE_ID) || undefined,
deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined,
};
}
function resolveMatrixEnvAccountToken(accountId: string): string {
return normalizeAccountId(accountId)
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
export function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]) || undefined,
password: clean(env[keys.password]) || undefined,
deviceId: clean(env[keys.deviceId]) || undefined,
deviceName: clean(env[keys.deviceName]) || undefined,
};
}
export function hasReadyMatrixEnvAuth(config: {
homeserver?: string;
userId?: string;
accessToken?: string;
password?: string;
}): boolean {
const homeserver = clean(config.homeserver);
const userId = clean(config.userId);
const accessToken = clean(config.accessToken);
const password = clean(config.password);
return Boolean(homeserver && (accessToken || (userId && password)));
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const homeserver =
clean(matrix.homeserver) || defaultScopedEnv.homeserver || globalEnv.homeserver;
const userId = clean(matrix.userId) || defaultScopedEnv.userId || globalEnv.userId;
const accessToken =
clean(matrix.accessToken) || defaultScopedEnv.accessToken || globalEnv.accessToken || undefined;
const password =
clean(matrix.password) || defaultScopedEnv.password || globalEnv.password || undefined;
const deviceId =
clean(matrix.deviceId) || defaultScopedEnv.deviceId || globalEnv.deviceId || undefined;
const deviceName =
clean(matrix.deviceName) || defaultScopedEnv.deviceName || globalEnv.deviceName || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export function resolveMatrixConfigForAccount(
cfg: CoreConfig,
accountId: string,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = resolveMatrixBaseConfig(cfg);
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
const globalEnv = resolveGlobalMatrixEnvConfig(env);
const accountHomeserver = clean(
typeof account.homeserver === "string" ? account.homeserver : undefined,
);
const accountUserId = clean(typeof account.userId === "string" ? account.userId : undefined);
const accountAccessToken = clean(
typeof account.accessToken === "string" ? account.accessToken : undefined,
);
const accountPassword = clean(
typeof account.password === "string" ? account.password : undefined,
);
const accountDeviceId = clean(
typeof account.deviceId === "string" ? account.deviceId : undefined,
);
const accountDeviceName = clean(
typeof account.deviceName === "string" ? account.deviceName : undefined,
);
const homeserver =
accountHomeserver || scopedEnv.homeserver || clean(matrix.homeserver) || globalEnv.homeserver;
const userId = accountUserId || scopedEnv.userId || clean(matrix.userId) || globalEnv.userId;
const accessToken =
accountAccessToken ||
scopedEnv.accessToken ||
clean(matrix.accessToken) ||
globalEnv.accessToken ||
undefined;
const password =
accountPassword ||
scopedEnv.password ||
clean(matrix.password) ||
globalEnv.password ||
undefined;
const deviceId =
accountDeviceId ||
scopedEnv.deviceId ||
clean(matrix.deviceId) ||
globalEnv.deviceId ||
undefined;
const deviceName =
accountDeviceName ||
scopedEnv.deviceName ||
clean(matrix.deviceName) ||
globalEnv.deviceName ||
undefined;
const accountInitialSyncLimit =
typeof account.initialSyncLimit === "number"
? Math.max(0, Math.floor(account.initialSyncLimit))
: undefined;
const initialSyncLimit =
accountInitialSyncLimit ??
(typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined);
const encryption =
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
return {
homeserver,
userId,
accessToken,
password,
deviceId,
deviceName,
initialSyncLimit,
encryption,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const accountId = params?.accountId;
const resolved = accountId
? resolveMatrixConfigForAccount(cfg, accountId, env)
: resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix-js.homeserver)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
let knownDeviceId = hasMatchingCachedToken
? cachedCredentials?.deviceId || resolved.deviceId
: resolved.deviceId;
if (!userId || !knownDeviceId) {
// Fetch whoami when we need to resolve userId and/or deviceId from token auth.
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
device_id?: string;
};
if (!userId) {
const fetchedUserId = whoami.user_id?.trim();
if (!fetchedUserId) {
throw new Error("Matrix whoami did not return user_id");
}
userId = fetchedUserId;
}
if (!knownDeviceId) {
knownDeviceId = whoami.device_id?.trim() || resolved.deviceId;
}
}
const shouldRefreshCachedCredentials =
!cachedCredentials ||
!hasMatchingCachedToken ||
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
await saveMatrixCredentials(
{
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
},
env,
accountId,
);
} else if (hasMatchingCachedToken) {
await touchMatrixCredentials(env, accountId);
}
return {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
password: resolved.password,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (cachedCredentials) {
await touchMatrixCredentials(env, accountId);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
password: resolved.password,
deviceId: cachedCredentials.deviceId || resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix-js.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix password is required when no access token is configured (matrix-js.password)",
);
}
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
password: resolved.password,
deviceId: login.device_id ?? resolved.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
await saveMatrixCredentials(
{
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: auth.deviceId,
},
env,
accountId,
);
return auth;
}

View File

@@ -1,65 +0,0 @@
import fs from "node:fs";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
export async function createMatrixClient(params: {
homeserver: string;
userId?: string;
accessToken: string;
password?: string;
deviceId?: string;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
autoBootstrapCrypto?: boolean;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const userId = params.userId?.trim() || "unknown";
const matrixClientUserId = params.userId?.trim() || undefined;
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({
storagePaths,
homeserver: params.homeserver,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId,
accountId: params.accountId,
});
const cryptoDatabasePrefix = `openclaw-matrix-js-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
password: params.password,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
recoveryKeyPath: storagePaths.recoveryKeyPath,
idbSnapshotPath: storagePaths.idbSnapshotPath,
cryptoDatabasePrefix,
autoBootstrapCrypto: params.autoBootstrapCrypto,
});
}

View File

@@ -1,100 +0,0 @@
import { ConsoleLogger, LogService } from "../sdk/logger.js";
let matrixSdkLoggingConfigured = false;
let matrixSdkLogMode: "default" | "quiet" = "default";
const matrixSdkBaseLogger = new ConsoleLogger();
type MatrixJsSdkLogger = {
trace: (...messageOrObject: unknown[]) => void;
debug: (...messageOrObject: unknown[]) => void;
info: (...messageOrObject: unknown[]) => void;
warn: (...messageOrObject: unknown[]) => void;
error: (...messageOrObject: unknown[]) => void;
getChild: (namespace: string) => MatrixJsSdkLogger;
};
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (!module.includes("MatrixHttpClient")) {
return false;
}
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
export function ensureMatrixSdkLoggingConfigured(): void {
if (!matrixSdkLoggingConfigured) {
matrixSdkLoggingConfigured = true;
}
applyMatrixSdkLogger();
}
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
matrixSdkLogMode = mode;
if (!matrixSdkLoggingConfigured) {
return;
}
applyMatrixSdkLogger();
}
export function createMatrixJsSdkClientLogger(prefix = "matrix-js"): MatrixJsSdkLogger {
return createMatrixJsSdkLoggerInstance(prefix);
}
function applyMatrixSdkLogger(): void {
if (matrixSdkLogMode === "quiet") {
LogService.setLogger({
trace: () => {},
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
});
return;
}
LogService.setLogger({
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
if (matrixSdkLogMode === "quiet") {
return;
}
(matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)(
prefix,
...messageOrObject,
);
};
return {
trace: (...messageOrObject) => log("trace", ...messageOrObject),
debug: (...messageOrObject) => log("debug", ...messageOrObject),
info: (...messageOrObject) => log("info", ...messageOrObject),
warn: (...messageOrObject) => log("warn", ...messageOrObject),
error: (...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) {
return;
}
log("error", ...messageOrObject);
},
getChild: (namespace: string) => {
const nextNamespace = namespace.trim();
return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix);
},
};
}

View File

@@ -1,4 +0,0 @@
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}

View File

@@ -1,112 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "./types.js";
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
const createMatrixClientMock = vi.hoisted(() => vi.fn());
vi.mock("./config.js", () => ({
resolveMatrixAuth: resolveMatrixAuthMock,
}));
vi.mock("./create-client.js", () => ({
createMatrixClient: createMatrixClientMock,
}));
import {
resolveSharedMatrixClient,
stopSharedClient,
stopSharedClientForAccount,
} from "./shared.js";
function authFor(accountId: string): MatrixAuth {
return {
homeserver: "https://matrix.example.org",
userId: `@${accountId}:example.org`,
accessToken: `token-${accountId}`,
password: "secret",
deviceId: `${accountId.toUpperCase()}-DEVICE`,
deviceName: `${accountId} device`,
initialSyncLimit: undefined,
encryption: false,
};
}
function createMockClient(name: string) {
const client = {
name,
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
getJoinedRooms: vi.fn(async () => [] as string[]),
crypto: undefined,
};
return client;
}
describe("resolveSharedMatrixClient", () => {
beforeEach(() => {
resolveMatrixAuthMock.mockReset();
createMatrixClientMock.mockReset();
});
afterEach(() => {
stopSharedClient();
vi.clearAllMocks();
});
it("keeps account clients isolated when resolves are interleaved", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
expect(firstMain).toBe(mainClient);
expect(firstPoe).toBe(poeClient);
expect(secondMain).toBe(mainClient);
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
expect(mainClient.start).toHaveBeenCalledTimes(1);
expect(poeClient.start).toHaveBeenCalledTimes(0);
});
it("stops only the targeted account client", async () => {
const mainAuth = authFor("main");
const poeAuth = authFor("ops");
const mainClient = createMockClient("main");
const poeClient = createMockClient("ops");
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
accountId === "ops" ? poeAuth : mainAuth,
);
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
if (accountId === "ops") {
return poeClient;
}
return mainClient;
});
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
stopSharedClientForAccount(mainAuth, "main");
expect(mainClient.stop).toHaveBeenCalledTimes(1);
expect(poeClient.stop).toHaveBeenCalledTimes(0);
stopSharedClient();
expect(poeClient.stop).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,193 +0,0 @@
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
import type { MatrixAuth } from "./types.js";
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
startPromise: Promise<void> | null;
};
const sharedClientStates = new Map<string, SharedMatrixClientState>();
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
return [
auth.homeserver,
auth.userId,
auth.accessToken,
auth.encryption ? "e2ee" : "plain",
accountId ?? DEFAULT_ACCOUNT_KEY,
].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
accountId?: string | null;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
password: params.auth.password,
deviceId: params.auth.deviceId,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
});
return {
client,
key: buildSharedClientKey(params.auth, params.accountId),
started: false,
cryptoReady: false,
startPromise: null,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) {
return;
}
if (params.state.startPromise) {
await params.state.startPromise;
return;
}
params.state.startPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await client.start();
params.state.started = true;
})();
try {
await params.state.startPromise;
} finally {
params.state.startPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth =
params.auth ??
(await resolveMatrixAuth({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
}));
const key = buildSharedClientKey(auth, params.accountId);
const shouldStart = params.startClient !== false;
const existingState = sharedClientStates.get(key);
if (existingState) {
if (shouldStart) {
await ensureSharedClientStarted({
state: existingState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return existingState.client;
}
const existingPromise = sharedClientPromises.get(key);
if (existingPromise) {
const pending = await existingPromise;
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
const creationPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
});
sharedClientPromises.set(key, creationPromise);
try {
const created = await creationPromise;
sharedClientStates.set(key, created);
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
} finally {
sharedClientPromises.delete(key);
}
}
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// matrix-js-sdk handles sync lifecycle in start() for this integration.
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
for (const state of sharedClientStates.values()) {
state.client.stop();
}
sharedClientStates.clear();
sharedClientPromises.clear();
}
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, accountId);
const state = sharedClientStates.get(key);
if (!state) {
return;
}
state.client.stop();
sharedClientStates.delete(key);
sharedClientPromises.delete(key);
}

View File

@@ -1,182 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixStoragePaths } from "./types.js";
export const DEFAULT_ACCOUNT_KEY = "default";
const STORAGE_META_FILENAME = "storage-meta.json";
function sanitizePathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizePathSegment(url.host);
}
} catch {
// fall through
}
return sanitizePathSegment(homeserver);
}
function hashAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return {
storagePath: path.join(stateDir, "credentials", "matrix-js", "bot-storage.json"),
cryptoPath: path.join(stateDir, "credentials", "matrix-js", "crypto"),
};
}
function resolveLegacyMatrixJsAccountRoot(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): string {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
return path.join(
stateDir,
"credentials",
"matrix-js",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
}
export function resolveMatrixStoragePaths(params: {
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixStoragePaths {
const env = params.env ?? process.env;
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
const userKey = sanitizePathSegment(params.userId);
const serverKey = resolveHomeserverKey(params.homeserver);
const tokenHash = hashAccessToken(params.accessToken);
const rootDir = path.join(
stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"),
accountKey,
tokenHash,
};
}
export function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
homeserver?: string;
userId?: string;
accessToken?: string;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): void {
const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
if (hasNewStorage) {
return;
}
const legacyAccountRoot =
params.homeserver && params.userId && params.accessToken
? resolveLegacyMatrixJsAccountRoot({
homeserver: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
accountId: params.accountId,
env: params.env,
})
: null;
if (legacyAccountRoot && fs.existsSync(legacyAccountRoot)) {
fs.mkdirSync(path.dirname(params.storagePaths.rootDir), { recursive: true });
try {
fs.renameSync(legacyAccountRoot, params.storagePaths.rootDir);
return;
} catch {
// Fall through to older one-off migration paths.
}
}
const legacy = resolveLegacyStoragePaths(params.env);
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) {
return;
}
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) {
try {
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
} catch {
// Ignore migration failures; new store will be created.
}
}
if (hasLegacyCrypto) {
try {
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
} catch {
// Ignore migration failures; new store will be created.
}
}
}
export function writeStorageMeta(params: {
storagePaths: MatrixStoragePaths;
homeserver: string;
userId: string;
accountId?: string | null;
}): void {
try {
const payload = {
homeserver: params.homeserver,
userId: params.userId,
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
accessTokenHash: params.storagePaths.tokenHash,
createdAt: new Date().toISOString(),
};
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8");
} catch {
// ignore meta write failures
}
}

View File

@@ -1,39 +0,0 @@
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
password?: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
export type MatrixStoragePaths = {
rootDir: string;
storagePath: string;
cryptoPath: string;
metaPath: string;
recoveryKeyPath: string;
idbSnapshotPath: string;
accountKey: string;
tokenHash: string;
};

View File

@@ -1,166 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
function credentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (normalized === DEFAULT_ACCOUNT_ID) {
return "credentials.json";
}
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
// Different raw IDs that normalize to the same value are the same logical account.
return `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix");
}
function resolveLegacyMatrixJsCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix-js");
}
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, credentialsFilename(accountId));
}
function resolveLegacyMatrixJsCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const dir = resolveLegacyMatrixJsCredentialsDir(env);
return path.join(dir, credentialsFilename(accountId));
}
function maybeMigrateLegacyMatrixJsCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const nextPath = resolveMatrixCredentialsPath(env, accountId);
if (fs.existsSync(nextPath)) {
return;
}
const legacyPath = resolveLegacyMatrixJsCredentialsPath(env, accountId);
if (!fs.existsSync(legacyPath)) {
return;
}
fs.mkdirSync(path.dirname(nextPath), { recursive: true });
try {
fs.renameSync(legacyPath, nextPath);
} catch {
// Best-effort compatibility migration only.
}
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
maybeMigrateLegacyMatrixJsCredentials(env, accountId);
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credPath)) {
return null;
}
const raw = fs.readFileSync(credPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return parsed as MatrixStoredCredentials;
} catch {
return null;
}
}
export async function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
maybeMigrateLegacyMatrixJsCredentials(env, accountId);
const credPath = resolveMatrixCredentialsPath(env, accountId);
const existing = loadMatrixCredentials(env, accountId);
const now = new Date().toISOString();
const toSave: MatrixStoredCredentials = {
...credentials,
createdAt: existing?.createdAt ?? now,
lastUsedAt: now,
};
await writeJsonFileAtomically(credPath, toSave);
}
export async function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): Promise<void> {
maybeMigrateLegacyMatrixJsCredentials(env, accountId);
const existing = loadMatrixCredentials(env, accountId);
if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env, accountId);
await writeJsonFileAtomically(credPath, existing);
}
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const credPath = resolveMatrixCredentialsPath(env, accountId);
const legacyPath = resolveLegacyMatrixJsCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
fs.unlinkSync(credPath);
}
if (fs.existsSync(legacyPath)) {
fs.unlinkSync(legacyPath);
}
} catch {
// ignore
}
}
export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): boolean {
// If userId is empty (token-based auth), only match homeserver
if (!config.userId) {
return stored.homeserver === config.homeserver;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@@ -1,157 +0,0 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
function resolveMissingMatrixPackages(): string[] {
try {
const req = createRequire(import.meta.url);
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
try {
req.resolve(pkg);
return false;
} catch {
return true;
}
});
} catch {
return [...REQUIRED_MATRIX_PACKAGES];
}
}
export function isMatrixSdkAvailable(): boolean {
return resolveMissingMatrixPackages().length === 0;
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
}
type CommandResult = {
code: number;
stdout: string;
stderr: string;
};
async function runFixedCommandWithTimeout(params: {
argv: string[];
cwd: string;
timeoutMs: number;
env?: NodeJS.ProcessEnv;
}): Promise<CommandResult> {
return await new Promise((resolve) => {
const [command, ...args] = params.argv;
if (!command) {
resolve({
code: 1,
stdout: "",
stderr: "command is required",
});
return;
}
const proc = spawn(command, args, {
cwd: params.cwd,
env: { ...process.env, ...params.env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timer: NodeJS.Timeout | null = null;
const finalize = (result: CommandResult) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
resolve(result);
};
proc.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
timer = setTimeout(() => {
proc.kill("SIGKILL");
finalize({
code: 124,
stdout,
stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
});
}, params.timeoutMs);
proc.on("error", (err) => {
finalize({
code: 1,
stdout,
stderr: err.message,
});
});
proc.on("close", (code) => {
finalize({
code: code ?? 1,
stdout,
stderr,
});
});
});
}
export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;
}): Promise<void> {
if (isMatrixSdkAvailable()) {
return;
}
const confirm = params.confirm;
if (confirm) {
const ok = await confirm(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
);
if (!ok) {
throw new Error(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
);
}
}
const root = resolvePluginRoot();
const command = fs.existsSync(path.join(root, "pnpm-lock.yaml"))
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await runFixedCommandWithTimeout({
argv: command,
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
});
if (result.code !== 0) {
throw new Error(
result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.",
);
}
if (!isMatrixSdkAvailable()) {
const missing = resolveMissingMatrixPackages();
throw new Error(
missing.length > 0
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
: "Matrix dependency install completed but Matrix dependencies are still missing.",
);
}
}

View File

@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { markdownToMatrixHtml } from "./format.js";
describe("markdownToMatrixHtml", () => {
it("renders basic inline formatting", () => {
const html = markdownToMatrixHtml("hi _there_ **boss** `code`");
expect(html).toContain("<em>there</em>");
expect(html).toContain("<strong>boss</strong>");
expect(html).toContain("<code>code</code>");
});
it("renders links as HTML", () => {
const html = markdownToMatrixHtml("see [docs](https://example.com)");
expect(html).toContain('<a href="https://example.com">docs</a>');
});
it("escapes raw HTML", () => {
const html = markdownToMatrixHtml("<b>nope</b>");
expect(html).toContain("&lt;b&gt;nope&lt;/b&gt;");
expect(html).not.toContain("<b>nope</b>");
});
it("flattens images into alt text", () => {
const html = markdownToMatrixHtml("![alt](https://example.com/img.png)");
expect(html).toContain("alt");
expect(html).not.toContain("<img");
});
it("preserves line breaks", () => {
const html = markdownToMatrixHtml("line1\nline2");
expect(html).toContain("<br");
});
});

View File

@@ -1,22 +0,0 @@
import MarkdownIt from "markdown-it";
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
typographer: false,
});
md.enable("strikethrough");
const { escapeHtml } = md.utils;
md.renderer.rules.image = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? "");
export function markdownToMatrixHtml(markdown: string): string {
const rendered = md.render(markdown ?? "");
return rendered.trimEnd();
}

View File

@@ -1,11 +0,0 @@
export { monitorMatrixProvider } from "./monitor/index.js";
export { probeMatrix } from "./probe.js";
export {
reactMatrixMessage,
resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix,
sendPollMatrix,
sendTypingMatrix,
} from "./send.js";
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";

View File

@@ -1,45 +0,0 @@
import { describe, expect, it } from "vitest";
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
describe("resolveMatrixAllowListMatch", () => {
it("matches full user IDs and prefixes", () => {
const userId = "@Alice:Example.org";
const direct = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["@alice:example.org"]),
userId,
});
expect(direct.allowed).toBe(true);
expect(direct.matchSource).toBe("id");
const prefixedMatrix = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]),
userId,
});
expect(prefixedMatrix.allowed).toBe(true);
expect(prefixedMatrix.matchSource).toBe("prefixed-id");
const prefixedUser = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["user:@alice:example.org"]),
userId,
});
expect(prefixedUser.allowed).toBe(true);
expect(prefixedUser.matchSource).toBe("prefixed-user");
});
it("ignores display names and localparts", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["alice", "Alice"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(false);
});
it("matches wildcard", () => {
const match = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(["*"]),
userId: "@alice:example.org",
});
expect(match.allowed).toBe(true);
expect(match.matchSource).toBe("wildcard");
});
});

View File

@@ -1,103 +0,0 @@
import type { AllowlistMatch } from "openclaw/plugin-sdk/matrix-js";
function normalizeAllowList(list?: Array<string | number>) {
return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean);
}
function normalizeMatrixUser(raw?: string | null): string {
const value = (raw ?? "").trim();
if (!value) {
return "";
}
if (!value.startsWith("@") || !value.includes(":")) {
return value.toLowerCase();
}
const withoutAt = value.slice(1);
const splitIndex = withoutAt.indexOf(":");
if (splitIndex === -1) {
return value.toLowerCase();
}
const localpart = withoutAt.slice(0, splitIndex).toLowerCase();
const server = withoutAt.slice(splitIndex + 1).toLowerCase();
if (!server) {
return value.toLowerCase();
}
return `@${localpart}:${server.toLowerCase()}`;
}
export function normalizeMatrixUserId(raw?: string | null): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return normalizeMatrixUser(trimmed.slice("matrix:".length));
}
if (lowered.startsWith("user:")) {
return normalizeMatrixUser(trimmed.slice("user:".length));
}
return normalizeMatrixUser(trimmed);
}
function normalizeMatrixAllowListEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return trimmed;
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("matrix:")) {
return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`;
}
if (lowered.startsWith("user:")) {
return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`;
}
return normalizeMatrixUser(trimmed);
}
export function normalizeMatrixAllowList(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry));
}
export type MatrixAllowListMatch = AllowlistMatch<
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
>;
export function resolveMatrixAllowListMatch(params: {
allowList: string[];
userId?: string;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) {
return { allowed: false };
}
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
const userId = normalizeMatrixUser(params.userId);
const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [
{ value: userId, source: "id" },
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
];
for (const candidate of candidates) {
if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) {
return {
allowed: true,
matchKey: candidate.value,
matchSource: candidate.source,
};
}
}
return { allowed: false };
}
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
return resolveMatrixAllowListMatch(params).allowed;
}

View File

@@ -1,75 +0,0 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
cfg: CoreConfig;
runtime: RuntimeEnv;
}) {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
runtime.log?.(message);
};
const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always";
const autoJoinAllowlist = new Set(
(cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
logVerbose("matrix: auto-join enabled for all invites");
} else {
logVerbose("matrix: auto-join enabled for allowlist invites");
}
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin === "allowlist") {
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
altAliases =
aliasState && Array.isArray(aliasState.alt_aliases)
? aliasState.alt_aliases
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean)
: [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.has("*") ||
autoJoinAllowlist.has(roomId) ||
(alias ? autoJoinAllowlist.has(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.has(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
});
}

View File

@@ -1,104 +0,0 @@
import type { MatrixClient } from "../sdk.js";
type DirectMessageCheck = {
roomId: string;
senderId?: string;
selfUserId?: string;
};
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
};
const DM_CACHE_TTL_MS = 30_000;
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
const log = opts.log ?? (() => {});
let lastDmUpdateMs = 0;
let cachedSelfUserId: string | null = null;
const memberCountCache = new Map<string, { count: number; ts: number }>();
const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) {
return cachedSelfUserId;
}
try {
cachedSelfUserId = await client.getUserId();
} catch {
cachedSelfUserId = null;
}
return cachedSelfUserId;
};
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
return;
}
lastDmUpdateMs = now;
try {
await client.dms.update();
} catch (err) {
log(`matrix: dm cache refresh failed (${String(err)})`);
}
};
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
const cached = memberCountCache.get(roomId);
const now = Date.now();
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
return cached.count;
}
try {
const members = await client.getJoinedRoomMembers(roomId);
const count = members.length;
memberCountCache.set(roomId, { count, ts: now });
return count;
} catch (err) {
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
return null;
}
};
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim();
if (!target) {
return false;
}
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;
} catch {
return false;
}
};
return {
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
await refreshDmCache();
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}
const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
return true;
}
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const directViaState =
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
if (directViaState) {
log(`matrix: dm detected via member state room=${roomId}`);
return true;
}
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
return false;
},
};
}

View File

@@ -1,234 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import { registerMatrixMonitorEvents } from "./events.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void;
function getSentNoticeBody(sendMessage: ReturnType<typeof vi.fn>, index = 0): string {
const calls = sendMessage.mock.calls as unknown[][];
const payload = (calls[index]?.[1] ?? {}) as { body?: string };
return payload.body ?? "";
}
function createHarness(params?: {
verifications?: Array<{
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
}>;
}) {
const listeners = new Map<string, (...args: unknown[]) => void>();
const onRoomMessage = vi.fn(async () => {});
const listVerifications = vi.fn(async () => params?.verifications ?? []);
const sendMessage = vi.fn(async () => "$notice");
const client = {
on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => {
listeners.set(eventName, listener);
return client;
}),
sendMessage,
crypto: {
listVerifications,
},
} as unknown as MatrixClient;
registerMatrixMonitorEvents({
client,
auth: { encryption: true } as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
formatNativeDependencyHint: vi.fn(() => "install hint"),
onRoomMessage,
});
const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined;
if (!roomEventListener) {
throw new Error("room.event listener was not registered");
}
return {
onRoomMessage,
sendMessage,
roomEventListener,
listVerifications,
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
};
}
describe("registerMatrixMonitorEvents verification routing", () => {
it("forwards reaction room events into the shared room handler", async () => {
const { onRoomMessage, sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$reaction1",
sender: "@alice:example.org",
type: EventType.Reaction,
origin_server_ts: Date.now(),
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg1",
key: "👍",
},
},
});
await vi.waitFor(() => {
expect(onRoomMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }),
);
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("posts verification request notices directly into the room", async () => {
const { onRoomMessage, sendMessage, roomMessageListener } = createHarness();
if (!roomMessageListener) {
throw new Error("room.message listener was not registered");
}
roomMessageListener("!room:example.org", {
event_id: "$req1",
sender: "@alice:example.org",
type: EventType.RoomMessage,
origin_server_ts: Date.now(),
content: {
msgtype: "m.key.verification.request",
body: "verification request",
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
expect(onRoomMessage).not.toHaveBeenCalled();
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification request received from @alice:example.org.");
expect(body).toContain('Open "Verify by emoji"');
});
it("posts ready-stage guidance for emoji verification", async () => {
const { sendMessage, roomEventListener } = createHarness();
roomEventListener("!room:example.org", {
event_id: "$ready-1",
sender: "@alice:example.org",
type: "m.key.verification.ready",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req-ready-1" },
},
});
await vi.waitFor(() => {
expect(sendMessage).toHaveBeenCalledTimes(1);
});
const body = getSentNoticeBody(sendMessage, 0);
expect(body).toContain("Matrix verification is ready with @alice:example.org.");
expect(body).toContain('Choose "Verify by emoji"');
});
it("posts SAS emoji/decimal details when verification summaries expose them", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-1",
transactionId: "$different-flow-id",
updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(),
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [6158, 1986, 3513],
emoji: [
["🎁", "Gift"],
["🌍", "Globe"],
["🐴", "Horse"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start2",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req2" },
},
});
await vi.waitFor(() => {
const bodies = (sendMessage.mock.calls as unknown[][]).map((call) =>
String((call[1] as { body?: string } | undefined)?.body ?? ""),
);
expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true);
expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true);
});
});
it("does not emit duplicate SAS notices for the same verification payload", async () => {
const { sendMessage, roomEventListener, listVerifications } = createHarness({
verifications: [
{
id: "verification-3",
transactionId: "$req3",
otherUserId: "@alice:example.org",
phaseName: "started",
sas: {
decimal: [1111, 2222, 3333],
emoji: [
["🚀", "Rocket"],
["🦋", "Butterfly"],
["📕", "Book"],
],
},
},
],
});
roomEventListener("!room:example.org", {
event_id: "$start3",
sender: "@alice:example.org",
type: "m.key.verification.start",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(sendMessage.mock.calls.length).toBeGreaterThan(0);
});
roomEventListener("!room:example.org", {
event_id: "$key3",
sender: "@alice:example.org",
type: "m.key.verification.key",
origin_server_ts: Date.now(),
content: {
"m.relates_to": { event_id: "$req3" },
},
});
await vi.waitFor(() => {
expect(listVerifications).toHaveBeenCalledTimes(2);
});
const sasBodies = sendMessage.mock.calls
.map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? ""))
.filter((body) => body.includes("SAS emoji:"));
expect(sasBodies).toHaveLength(1);
});
});

View File

@@ -1,400 +0,0 @@
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";
import {
isMatrixVerificationEventType,
isMatrixVerificationRequestMsgType,
matrixVerificationConstants,
} from "./verification-utils.js";
const MAX_TRACKED_VERIFICATION_EVENTS = 1024;
type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other";
type MatrixVerificationSummaryLike = {
id: string;
transactionId?: string;
roomId?: string;
otherUserId: string;
phaseName: string;
updatedAt?: string;
completed?: boolean;
sas?: {
decimal?: [number, number, number];
emoji?: Array<[string, string]>;
};
};
function trimMaybeString(input: unknown): string | null {
if (typeof input !== "string") {
return null;
}
const trimmed = input.trim();
return trimmed.length > 0 ? trimmed : null;
}
function readVerificationSignal(event: MatrixRawEvent): {
stage: MatrixVerificationStage;
flowId: string | null;
} | null {
const type = trimMaybeString(event?.type) ?? "";
const content = event?.content ?? {};
const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? "";
const relatedEventId = trimMaybeString(
(content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id,
);
const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id);
if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) {
return {
stage: "request",
flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId,
};
}
if (!isMatrixVerificationEventType(type)) {
return null;
}
const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id);
if (type === `${matrixVerificationConstants.eventPrefix}request`) {
return { stage: "request", flowId };
}
if (type === `${matrixVerificationConstants.eventPrefix}ready`) {
return { stage: "ready", flowId };
}
if (type === "m.key.verification.start") {
return { stage: "start", flowId };
}
if (type === "m.key.verification.cancel") {
return { stage: "cancel", flowId };
}
if (type === "m.key.verification.done") {
return { stage: "done", flowId };
}
return { stage: "other", flowId };
}
function formatVerificationStageNotice(params: {
stage: MatrixVerificationStage;
senderId: string;
event: MatrixRawEvent;
}): string | null {
const { stage, senderId, event } = params;
const content = event.content as { code?: unknown; reason?: unknown };
switch (stage) {
case "request":
return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`;
case "ready":
return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`;
case "start":
return `Matrix verification started with ${senderId}.`;
case "done":
return `Matrix verification completed with ${senderId}.`;
case "cancel": {
const code = trimMaybeString(content.code);
const reason = trimMaybeString(content.reason);
if (code && reason) {
return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`;
}
if (reason) {
return `Matrix verification cancelled by ${senderId} (${reason}).`;
}
return `Matrix verification cancelled by ${senderId}.`;
}
default:
return null;
}
}
function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null {
const sas = summary.sas;
if (!sas) {
return null;
}
const emojiLine =
Array.isArray(sas.emoji) && sas.emoji.length > 0
? `SAS emoji: ${sas.emoji
.map(
([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`,
)
.join(" | ")}`
: null;
const decimalLine =
Array.isArray(sas.decimal) && sas.decimal.length === 3
? `SAS decimal: ${sas.decimal.join(" ")}`
: null;
if (!emojiLine && !decimalLine) {
return null;
}
const lines = [`Matrix verification SAS with ${summary.otherUserId}:`];
if (emojiLine) {
lines.push(emojiLine);
}
if (decimalLine) {
lines.push(decimalLine);
}
lines.push("If both sides match, choose 'They match' in your Matrix app.");
return lines.join("\n");
}
function resolveVerificationFlowCandidates(params: {
event: MatrixRawEvent;
flowId: string | null;
}): string[] {
const { event, flowId } = params;
const content = event.content as {
transaction_id?: unknown;
"m.relates_to"?: { event_id?: unknown };
};
const candidates = new Set<string>();
const add = (value: unknown) => {
const normalized = trimMaybeString(value);
if (normalized) {
candidates.add(normalized);
}
};
add(flowId);
add(event.event_id);
add(content.transaction_id);
add(content["m.relates_to"]?.event_id);
return Array.from(candidates);
}
function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number {
const ts = Date.parse(summary.updatedAt ?? "");
return Number.isFinite(ts) ? ts : 0;
}
async function resolveVerificationSummaryForSignal(
client: MatrixClient,
params: {
event: MatrixRawEvent;
senderId: string;
flowId: string | null;
},
): Promise<MatrixVerificationSummaryLike | null> {
if (!client.crypto) {
return null;
}
const list = await client.crypto.listVerifications();
if (list.length === 0) {
return null;
}
const candidates = resolveVerificationFlowCandidates({
event: params.event,
flowId: params.flowId,
});
const byTransactionId = list.find((entry) =>
candidates.some((candidate) => entry.transactionId === candidate),
);
if (byTransactionId) {
return byTransactionId;
}
// Fallback for flows where transaction IDs do not match room event IDs consistently.
const byUser = list
.filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true)
.sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0];
return byUser ?? null;
}
function trackBounded(set: Set<string>, value: string): boolean {
if (!value || set.has(value)) {
return false;
}
set.add(value);
if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) {
const oldest = set.values().next().value;
if (typeof oldest === "string") {
set.delete(oldest);
}
}
return true;
}
async function sendVerificationNotice(params: {
client: MatrixClient;
roomId: string;
body: string;
logVerboseMessage: (message: string) => void;
}): Promise<void> {
const roomId = trimMaybeString(params.roomId);
if (!roomId) {
return;
}
try {
await params.client.sendMessage(roomId, {
msgtype: "m.notice",
body: params.body,
});
} catch (err) {
params.logVerboseMessage(
`matrix: failed sending verification notice room=${roomId}: ${String(err)}`,
);
}
}
export function registerMatrixMonitorEvents(params: {
client: MatrixClient;
auth: MatrixAuth;
logVerboseMessage: (message: string) => void;
warnedEncryptedRooms: Set<string>;
warnedCryptoMissingRooms: Set<string>;
logger: RuntimeLogger;
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
const {
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint,
onRoomMessage,
} = params;
const routedVerificationEvents = new Set<string>();
const routedVerificationSasFingerprints = new Set<string>();
const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => {
const senderId = trimMaybeString(event?.sender);
if (!senderId) {
return false;
}
const signal = readVerificationSignal(event);
if (!signal) {
return false;
}
void (async () => {
const flowId = signal.flowId;
const sourceEventId = trimMaybeString(event?.event_id);
const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`;
if (!trackBounded(routedVerificationEvents, sourceFingerprint)) {
return;
}
const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event });
const summary = await resolveVerificationSummaryForSignal(client, {
event,
senderId,
flowId,
}).catch(() => null);
const sasNotice = summary ? formatVerificationSasNotice(summary) : null;
const notices: string[] = [];
if (stageNotice) {
notices.push(stageNotice);
}
if (summary && sasNotice) {
const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`;
if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) {
notices.push(sasNotice);
}
}
if (notices.length === 0) {
return;
}
for (const body of notices) {
await sendVerificationNotice({
client,
roomId,
body,
logVerboseMessage,
});
}
})().catch((err) => {
logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`);
});
return true;
};
client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
if (routeVerificationEvent(roomId, event)) {
return;
}
void onRoomMessage(roomId, event);
});
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on(
"room.failed_decryption",
async (roomId: string, event: MatrixRawEvent, error: Error) => {
logger.warn("Failed to decrypt message", {
roomId,
eventId: event.event_id,
error: error.message,
});
logVerboseMessage(
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
);
},
);
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const sender = event?.sender ?? "unknown";
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
logVerboseMessage(
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
);
});
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
});
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
const eventType = event?.type ?? "unknown";
if (eventType === EventType.RoomMessageEncrypted) {
logVerboseMessage(
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
);
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
warnedEncryptedRooms.add(roomId);
const warning =
"matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt";
logger.warn(warning, { roomId });
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
warnedCryptoMissingRooms.add(roomId);
const hint = formatNativeDependencyHint({
packageName: "@matrix-org/matrix-sdk-crypto-nodejs",
manager: "pnpm",
downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js",
});
const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`;
logger.warn(warning, { roomId });
}
return;
}
if (eventType === EventType.RoomMember) {
const membership = (event?.content as { membership?: string } | undefined)?.membership;
const stateKey = (event as { state_key?: string }).state_key ?? "";
logVerboseMessage(
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
);
}
if (eventType === EventType.Reaction) {
void onRoomMessage(roomId, event);
return;
}
routeVerificationEvent(roomId, event);
});
}

View File

@@ -1,805 +0,0 @@
import {
createReplyPrefixOptions,
createTypingCallbacks,
ensureConfiguredAcpRouteReady,
formatAllowlistMatchMeta,
getSessionBindingService,
logInboundDrop,
logTypingFailure,
resolveAgentIdFromSessionKey,
resolveConfiguredAcpRoute,
resolveControlCommandGate,
type PluginRuntime,
type ReplyPayload,
type RuntimeEnv,
type RuntimeLogger,
} from "openclaw/plugin-sdk/matrix-js";
import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js";
import {
formatPollAsText,
isPollStartType,
parsePollStartContent,
type PollStartContent,
} from "../poll-types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
import {
normalizeMatrixAllowList,
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
} from "./allowlist.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { createMatrixThreadContextResolver } from "./thread-context.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { EventType, RelationType } from "./types.js";
import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
export type MatrixMonitorHandlerParams = {
client: MatrixClient;
core: PluginRuntime;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
logger: RuntimeLogger;
logVerboseMessage: (message: string) => void;
allowFrom: string[];
roomsConfig?: Record<string, MatrixRoomConfig>;
mentionRegexes: ReturnType<PluginRuntime["channel"]["mentions"]["buildMentionRegexes"]>;
groupPolicy: "open" | "allowlist" | "disabled";
replyToMode: ReplyToMode;
threadReplies: "off" | "inbound" | "always";
dmEnabled: boolean;
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
textLimit: number;
mediaMaxBytes: number;
startupMs: number;
startupGraceMs: number;
directTracker: {
isDirectMessage: (params: {
roomId: string;
senderId: string;
selfUserId: string;
}) => Promise<boolean>;
};
getRoomInfo: (
roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
};
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
const {
client,
core,
cfg,
accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
} = params;
let cachedStoreAllowFrom: {
value: string[];
expiresAtMs: number;
} | null = null;
const pairingReplySentAtMsBySender = new Map<string, number>();
const resolveThreadContext = createMatrixThreadContextResolver({
client,
getMemberDisplayName,
logVerboseMessage,
});
const readStoreAllowFrom = async (): Promise<string[]> => {
const now = Date.now();
if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
return cachedStoreAllowFrom.value;
}
const value = await core.channel.pairing
.readAllowFromStore({
channel: "matrix-js",
env: process.env,
accountId,
})
.catch(() => []);
cachedStoreAllowFrom = {
value,
expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
};
return value;
};
const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
const now = Date.now();
if (created) {
pairingReplySentAtMsBySender.set(senderId, now);
return true;
}
const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
return false;
}
pairingReplySentAtMsBySender.set(senderId, now);
if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
if (typeof oldestSender === "string") {
pairingReplySentAtMsBySender.delete(oldestSender);
}
}
return true;
};
return async (roomId: string, event: MatrixRawEvent) => {
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted payloads are emitted separately after decryption.
return;
}
const isPollEvent = isPollStartType(eventType);
const isReactionEvent = eventType === EventType.Reaction;
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
if (
eventType !== EventType.RoomMessage &&
!isPollEvent &&
!isLocationEvent &&
!isReactionEvent
) {
return;
}
logVerboseMessage(
`matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
);
if (event.unsigned?.redacted_because) {
return;
}
const senderId = event.sender;
if (!senderId) {
return;
}
const selfUserId = await client.getUserId();
if (senderId === selfUserId) {
return;
}
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
}
if (
typeof eventTs !== "number" &&
typeof eventAge === "number" &&
eventAge > startupGraceMs
) {
return;
}
const roomInfo = await getRoomInfo(roomId);
const roomName = roomInfo.name;
const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean);
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
body: pollText,
} as unknown as RoomMessageEventContent;
} else {
return;
}
}
if (
eventType === EventType.RoomMessage &&
isMatrixVerificationRoomMessage({
msgtype: (content as { msgtype?: unknown }).msgtype,
body: content.body,
})
) {
logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
return;
}
const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
eventType,
content: content as LocationMessageEventContent,
});
const relates = content["m.relates_to"];
if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) {
return;
}
}
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
senderId,
selfUserId,
});
const isRoom = !isDirectMessage;
if (isRoom && groupPolicy === "disabled") {
return;
}
const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases: roomAliases,
name: roomName,
})
: undefined;
const roomConfig = roomConfigInfo?.config;
const roomMatchMeta = roomConfigInfo
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`
: "matchKey=none matchSource=none";
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await readStoreAllowFrom();
const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
return;
}
if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom,
userId: senderId,
});
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (!isReactionEvent && dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix-js",
id: senderId,
accountId,
meta: { name: senderName },
});
if (shouldSendPairingReply(senderId, created)) {
const pairingReply = core.channel.pairing.buildPairingReply({
channel: "matrix-js",
idLine: `Your Matrix user id: ${senderId}`,
code,
});
logVerboseMessage(
created
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
await sendMessageMatrix(
`room:${roomId}`,
created
? pairingReply
: `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
{ client },
);
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
} else {
logVerboseMessage(
`matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
);
}
}
if (isReactionEvent || dmPolicy !== "pairing") {
logVerboseMessage(
`matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
return;
}
}
}
const roomUsers = roomConfig?.users ?? [];
if (isRoom && roomUsers.length > 0) {
const userMatch = resolveMatrixAllowListMatch({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
});
if (!userMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch,
)})`,
);
return;
}
}
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
});
if (!groupAllowMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
groupAllowMatch,
)})`,
);
return;
}
}
if (isRoom) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
if (isReactionEvent) {
await handleInboundMatrixReaction({
client,
core,
cfg,
accountId,
roomId,
event,
senderId,
senderLabel: senderName,
selfUserId,
isDirectMessage,
logVerboseMessage,
});
return;
}
const rawBody =
locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : "");
let media: {
path: string;
contentType?: string;
placeholder: string;
} | null = null;
const contentUrl =
"url" in content && typeof content.url === "string" ? content.url : undefined;
const contentFile =
"file" in content && content.file && typeof content.file === "object"
? content.file
: undefined;
const mediaUrl = contentUrl ?? contentFile?.url;
if (!rawBody && !mediaUrl) {
return;
}
const contentInfo =
"info" in content && content.info && typeof content.info === "object"
? (content.info as { mimetype?: string; size?: number })
: undefined;
const contentType = contentInfo?.mimetype;
const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
if (mediaUrl?.startsWith("mxc://")) {
try {
media = await downloadMatrixMedia({
client,
mxcUrl: mediaUrl,
contentType,
sizeBytes: contentSize,
maxBytes: mediaMaxBytes,
file: contentFile,
});
} catch (err) {
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
}
}
const bodyText = rawBody || media?.placeholder || "";
if (!bodyText) {
return;
}
const { wasMentioned, hasExplicitMention } = resolveMentions({
content,
userId: selfUserId,
text: bodyText,
mentionRegexes,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "matrix-js",
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeMatrixAllowList(roomUsers),
userId: senderId,
})
: false;
const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
const commandAuthorized = commandGate.commandAuthorized;
if (isRoom && commandGate.shouldBlock) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix-js",
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
const shouldRequireMention = isRoom
? roomConfig?.autoReply === true
? false
: roomConfig?.autoReply === false
? true
: typeof roomConfig?.requireMention === "boolean"
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
allowTextCommands &&
isRoom &&
shouldRequireMention &&
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommandInMessage;
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info("skipping room message", { roomId, reason: "no-mention" });
return;
}
const messageId = event.event_id ?? "";
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
const threadRootId = resolveMatrixThreadRootId({ event, content });
const threadTarget = resolveMatrixThreadTarget({
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
});
const threadContext = threadRootId
? await resolveThreadContext({ roomId, threadRootId })
: undefined;
const baseRoute = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix-js",
accountId,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? senderId : roomId,
},
});
const bindingConversationId =
threadRootId && threadRootId !== messageId ? threadRootId : roomId;
const bindingParentConversationId = bindingConversationId === roomId ? undefined : roomId;
const sessionBindingService = getSessionBindingService();
const runtimeBinding = sessionBindingService.resolveByConversation({
channel: "matrix-js",
accountId,
conversationId: bindingConversationId,
parentConversationId: bindingParentConversationId,
});
const configuredRoute =
runtimeBinding == null
? resolveConfiguredAcpRoute({
cfg,
route: baseRoute,
channel: "matrix-js",
accountId,
conversationId: bindingConversationId,
parentConversationId: bindingParentConversationId,
})
: null;
const configuredBinding = configuredRoute?.configuredBinding ?? null;
if (!runtimeBinding && configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg,
configuredBinding,
});
if (!ensured.ok) {
logInboundDrop({
log: logVerboseMessage,
channel: "matrix-js",
reason: "configured ACP binding unavailable",
target: configuredBinding.spec.conversationId,
});
return;
}
}
const boundSessionKey = runtimeBinding?.targetSessionKey?.trim();
const route =
runtimeBinding && boundSessionKey
? {
...baseRoute,
sessionKey: boundSessionKey,
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || baseRoute.agentId,
matchedBy: "binding.channel" as const,
}
: (configuredRoute?.route ?? baseRoute);
if (runtimeBinding) {
sessionBindingService.touch(runtimeBinding.bindingId, eventTs);
}
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
To: `room:${roomId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName,
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix-js" as const,
Surface: "matrix-js" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
MessageThreadId: threadTarget,
ThreadStarterBody: threadContext?.threadStarterBody,
Timestamp: eventTs ?? undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...locationPayload?.context,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix-js" as const,
OriginatingTo: `room:${roomId}`,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
updateLastRoute: isDirectMessage
? {
sessionKey: route.mainSessionKey,
channel: "matrix-js",
to: `room:${roomId}`,
accountId: route.accountId,
}
: undefined,
onRecordError: (err) => {
logger.warn("failed updating session meta", {
error: String(err),
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
});
},
});
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({
cfg,
agentId: route.agentId,
accountId,
});
const shouldAckReaction = () =>
Boolean(
ackReaction &&
core.channel.reactions.shouldAckReaction({
scope: ackScope,
isDirect: isDirectMessage,
isGroup: isRoom,
isMentionableGroup: isRoom,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned: wasMentioned || shouldBypassMention,
shouldBypassMention,
}),
);
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
const replyTarget = ctxPayload.To;
if (!replyTarget) {
runtime.error?.("matrix: missing reply target");
return;
}
if (messageId) {
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: route.accountId,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "matrix-js",
accountId: route.accountId,
});
const typingCallbacks = createTypingCallbacks({
start: () => sendTypingMatrix(roomId, true, undefined, client),
stop: () => sendTypingMatrix(roomId, false, undefined, client),
onStartError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "start",
target: roomId,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: logVerboseMessage,
channel: "matrix-js",
action: "stop",
target: roomId,
error: err,
});
},
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
await deliverMatrixReplies({
replies: [payload],
roomId,
client,
runtime,
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
});
markDispatchIdle();
if (!queuedFinal) {
return;
}
didSendReply = true;
const finalCount = counts.final;
logVerboseMessage(
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
if (didSendReply) {
const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160);
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, {
sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
});
}
} catch (err) {
runtime.error?.(`matrix handler failed: ${String(err)}`);
}
};
}

View File

@@ -1,456 +0,0 @@
import { format } from "node:util";
import {
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
type RuntimeEnv,
} from "openclaw/plugin-sdk/matrix-js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import { resolveMatrixAccount } from "../accounts.js";
import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClientForAccount,
} from "../client.js";
import { updateMatrixAccountConfig } from "../config-update.js";
import { syncMatrixOwnProfile } from "../profile.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
import { ensureMatrixStartupVerification } from "./startup-verification.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
initialSyncLimit?: number;
replyToMode?: ReplyToMode;
accountId?: string | null;
};
const DEFAULT_MEDIA_MAX_MB = 20;
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.["matrix-js"]?.enabled === false) {
return;
}
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
const runtime: RuntimeEnv = opts.runtime ?? {
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug?.(message);
};
const normalizeUserEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^user:/i, "")
.trim();
const normalizeRoomEntry = (raw: string) =>
raw
.replace(/^matrix:/i, "")
.replace(/^(room|channel):/i, "")
.trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const resolveUserAllowlist = async (
label: string,
list?: Array<string | number>,
): Promise<string[]> => {
let allowList = list ?? [];
if (allowList.length === 0) {
return allowList.map(String);
}
const entries = allowList
.map((entry) => normalizeUserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (entries.length === 0) {
return allowList.map(String);
}
const mapping: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
const pending: string[] = [];
for (const entry of entries) {
if (isMatrixUserId(entry)) {
additions.push(normalizeMatrixUserId(entry));
continue;
}
pending.push(entry);
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
runtime,
});
for (const entry of resolved) {
if (entry.resolved && entry.id) {
const normalizedId = normalizeMatrixUserId(entry.id);
additions.push(normalizedId);
mapping.push(`${entry.input}${normalizedId}`);
} else {
unresolved.push(entry.input);
}
}
}
allowList = mergeAllowlist({ existing: allowList, additions });
summarizeMapping(label, mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
`${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
);
}
return allowList.map(String);
};
// Resolve account-specific config for multi-account support
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
const accountConfig = account.config;
const allowlistOnly = accountConfig.allowlistOnly === true;
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const mapping: string[] = [];
const unresolved: string[] = [];
const nextRooms: Record<string, (typeof roomsConfig)[string]> = {};
if (roomsConfig["*"]) {
nextRooms["*"] = roomsConfig["*"];
}
const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> =
[];
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
if (entry === "*") {
continue;
}
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomConfig;
}
if (cleaned !== entry) {
mapping.push(`${entry}${cleaned}`);
}
continue;
}
pending.push({ input: entry, query: trimmed, config: roomConfig });
}
if (pending.length > 0) {
const resolved = await resolveMatrixTargets({
cfg,
inputs: pending.map((entry) => entry.query),
kind: "group",
runtime,
});
resolved.forEach((entry, index) => {
const source = pending[index];
if (!source) {
return;
}
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = source.config;
}
mapping.push(`${source.input}${entry.id}`);
} else {
unresolved.push(source.input);
}
});
}
roomsConfig = nextRooms;
summarizeMapping("matrix rooms", mapping, unresolved, runtime);
if (unresolved.length > 0) {
runtime.log?.(
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
);
}
}
if (roomsConfig && Object.keys(roomsConfig).length > 0) {
const nextRooms = { ...roomsConfig };
for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) {
const users = roomConfig?.users ?? [];
if (users.length === 0) {
continue;
}
const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users);
if (resolvedUsers !== users) {
nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers };
}
}
roomsConfig = nextRooms;
}
cfg = {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
dm: {
...cfg.channels?.["matrix-js"]?.dm,
allowFrom,
},
groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))
: auth.initialSyncLimit;
const authWithLimit =
resolvedInitialSyncLimit === auth.initialSyncLimit
? auth
: { ...auth, initialSyncLimit: resolvedInitialSyncLimit };
const client = await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
startClient: false,
accountId: opts.accountId ?? undefined,
});
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "matrix-js",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => logVerboseMessage(message),
});
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = accountConfig.threadReplies ?? "inbound";
const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
cfg,
channel: "matrix-js",
accountId: account.accountId,
});
const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
cfg,
channel: "matrix-js",
accountId: account.accountId,
});
const dmConfig = accountConfig.dm;
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
const warnedCryptoMissingRooms = new Set<string>();
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
const handleRoomMessage = createMatrixRoomMessageHandler({
client,
core,
cfg,
accountId: account.accountId,
runtime,
logger,
logVerboseMessage,
allowFrom,
roomsConfig,
mentionRegexes,
groupPolicy,
replyToMode,
threadReplies,
dmEnabled,
dmPolicy,
textLimit,
mediaMaxBytes,
startupMs,
startupGraceMs,
directTracker,
getRoomInfo,
getMemberDisplayName,
});
registerMatrixMonitorEvents({
client,
auth,
logVerboseMessage,
warnedEncryptedRooms,
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
onRoomMessage: handleRoomMessage,
});
logVerboseMessage("matrix: starting client");
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
accountId: opts.accountId,
});
logVerboseMessage("matrix: client started");
const threadBindingManager = await createMatrixThreadBindingManager({
accountId: account.accountId,
auth,
client,
env: process.env,
idleTimeoutMs: threadBindingIdleTimeoutMs,
maxAgeMs: threadBindingMaxAgeMs,
logVerboseMessage,
});
logVerboseMessage(
`matrix: thread bindings ready account=${threadBindingManager.accountId} idleMs=${threadBindingIdleTimeoutMs} maxAgeMs=${threadBindingMaxAgeMs}`,
);
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
try {
const profileSync = await syncMatrixOwnProfile({
client,
userId: auth.userId,
displayName: accountConfig.name,
avatarUrl: accountConfig.avatarUrl,
loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes),
});
if (profileSync.displayNameUpdated) {
logger.info(`matrix: profile display name updated for ${auth.userId}`);
}
if (profileSync.avatarUpdated) {
logger.info(`matrix: profile avatar updated for ${auth.userId}`);
}
if (
profileSync.convertedAvatarFromHttp &&
profileSync.resolvedAvatarUrl &&
accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl
) {
const latestCfg = core.config.loadConfig() as CoreConfig;
const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, {
avatarUrl: profileSync.resolvedAvatarUrl,
});
await core.config.writeConfigFile(updatedCfg as never);
logVerboseMessage(
`matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`,
);
}
} catch (err) {
logger.warn("matrix: failed to sync profile from config", { error: String(err) });
}
// If E2EE is enabled, report device verification status and request self-verification
// when configured and the device is still unverified.
if (auth.encryption && client.crypto) {
try {
const startupVerification = await ensureMatrixStartupVerification({
client,
auth,
accountConfig,
accountId: account.accountId,
env: process.env,
});
if (startupVerification.kind === "verified") {
logger.info("matrix: device is verified and ready for encrypted rooms");
} else if (
startupVerification.kind === "disabled" ||
startupVerification.kind === "cooldown" ||
startupVerification.kind === "pending" ||
startupVerification.kind === "request-failed"
) {
logger.info(
"matrix: device not verified — run 'openclaw matrix-js verify device <key>' to enable E2EE",
);
if (startupVerification.kind === "pending") {
logger.info(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
} else if (startupVerification.kind === "cooldown") {
logVerboseMessage(
`matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`,
);
} else if (startupVerification.kind === "request-failed") {
logger.debug?.("Matrix startup verification request failed (non-fatal)", {
error: startupVerification.error ?? "unknown",
});
}
} else if (startupVerification.kind === "requested") {
logger.info(
"matrix: device not verified — requested verification in another Matrix client",
);
}
} catch (err) {
logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", {
error: String(err),
});
}
}
await new Promise<void>((resolve) => {
const onAbort = () => {
try {
threadBindingManager.stop();
logVerboseMessage("matrix: stopping client");
stopSharedClientForAccount(auth, opts.accountId);
} finally {
setActiveMatrixClient(null, opts.accountId);
resolve();
}
};
if (opts.abortSignal?.aborted) {
onAbort();
return;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -1,100 +0,0 @@
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk/matrix-js";
import type { LocationMessageEventContent } from "../sdk.js";
import { EventType } from "./types.js";
export type MatrixLocationPayload = {
text: string;
context: ReturnType<typeof toLocationContext>;
};
type GeoUriParams = {
latitude: number;
longitude: number;
accuracy?: number;
};
function parseGeoUri(value: string): GeoUriParams | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!trimmed.toLowerCase().startsWith("geo:")) {
return null;
}
const payload = trimmed.slice(4);
const [coordsPart, ...paramParts] = payload.split(";");
const coords = coordsPart.split(",");
if (coords.length < 2) {
return null;
}
const latitude = Number.parseFloat(coords[0] ?? "");
const longitude = Number.parseFloat(coords[1] ?? "");
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const params = new Map<string, string>();
for (const part of paramParts) {
const segment = part.trim();
if (!segment) {
continue;
}
const eqIndex = segment.indexOf("=");
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
const key = rawKey.trim().toLowerCase();
if (!key) {
continue;
}
const valuePart = rawValue.trim();
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
}
const accuracyRaw = params.get("u");
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
return {
latitude,
longitude,
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
};
}
export function resolveMatrixLocation(params: {
eventType: string;
content: LocationMessageEventContent;
}): MatrixLocationPayload | null {
const { eventType, content } = params;
const isLocation =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
if (!isLocation) {
return null;
}
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
if (!geoUri) {
return null;
}
const parsed = parseGeoUri(geoUri);
if (!parsed) {
return null;
}
const caption = typeof content.body === "string" ? content.body.trim() : "";
const location: NormalizedLocation = {
latitude: parsed.latitude,
longitude: parsed.longitude,
accuracy: parsed.accuracy,
caption: caption || undefined,
source: "pin",
isLive: false,
};
return {
text: formatLocationText(location),
context: toLocationContext(location),
};
}

View File

@@ -1,102 +0,0 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import { downloadMatrixMedia } from "./media.js";
describe("downloadMatrixMedia", () => {
const saveMediaBuffer = vi.fn().mockResolvedValue({
path: "/tmp/media",
contentType: "image/png",
});
const runtimeStub = {
channel: {
media: {
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
},
},
} as unknown as PluginRuntime;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const result = await downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
maxBytes: 1024,
file,
});
// decryptMedia should be called with just the file object (it handles download internally)
expect(decryptMedia).toHaveBeenCalledWith(file);
expect(saveMediaBuffer).toHaveBeenCalledWith(
Buffer.from("decrypted"),
"image/png",
"inbound",
1024,
);
expect(result?.path).toBe("/tmp/media");
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
await expect(
downloadMatrixMedia({
client,
mxcUrl: "mxc://example/file",
contentType: "image/png",
sizeBytes: 2048,
maxBytes: 1024,
file,
}),
).rejects.toThrow("Matrix media exceeds configured size limit");
expect(decryptMedia).not.toHaveBeenCalled();
expect(saveMediaBuffer).not.toHaveBeenCalled();
});
});

View File

@@ -1,117 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
// Type for encrypted file info
type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
// The client wrapper exposes mxcToHttp for Matrix media URIs.
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) {
return null;
}
// Use the client's download method which handles auth
try {
const raw = await params.client.downloadContent(params.mxcUrl);
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
}
}
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses the Matrix crypto adapter's decryptMedia helper.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
file: EncryptedFile;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
if (!params.client.crypto) {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// decryptMedia handles downloading and decrypting the encrypted content internally
const decrypted = await params.client.crypto.decryptMedia(
params.file as Parameters<typeof params.client.crypto.decryptMedia>[0],
);
if (decrypted.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: decrypted };
}
export async function downloadMatrixMedia(params: {
client: MatrixClient;
mxcUrl: string;
contentType?: string;
sizeBytes?: number;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
client: params.client,
file: params.file,
maxBytes: params.maxBytes,
});
} else {
// Unencrypted media
fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
}
if (!fetched) {
return null;
}
const headerType = params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: "[matrix media]",
};
}

View File

@@ -1,154 +0,0 @@
import { describe, expect, it, vi } from "vitest";
// Mock the runtime before importing resolveMentions
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
mentions: {
matchesMentionPatterns: (text: string, patterns: RegExp[]) =>
patterns.some((p) => p.test(text)),
},
},
}),
}));
import { resolveMentions } from "./mentions.js";
describe("resolveMentions", () => {
const userId = "@bot:matrix.org";
const mentionRegexes = [/@bot/i];
describe("m.mentions field", () => {
it("detects mention via m.mentions.user_ids", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello",
"m.mentions": { user_ids: ["@bot:matrix.org"] },
},
userId,
text: "hello",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
expect(result.hasExplicitMention).toBe(true);
});
it("detects room mention via m.mentions.room", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello everyone",
"m.mentions": { room: true },
},
userId,
text: "hello everyone",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("formatted_body matrix.to links", () => {
it("detects mention in formatted_body with plain user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/@bot:matrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention in formatted_body with URL-encoded user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: '<a href="https://matrix.to/#/%40bot%3Amatrix.org">Bot</a>: hello',
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("detects mention with single quotes in href", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot: hello",
formatted_body: "<a href='https://matrix.to/#/@bot:matrix.org'>Bot</a>: hello",
},
userId,
text: "Bot: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(true);
});
it("does not detect mention for different user ID", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Other: hello",
formatted_body: '<a href="https://matrix.to/#/@other:matrix.org">Other</a>: hello',
},
userId,
text: "Other: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
it("does not false-positive on partial user ID match", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "Bot2: hello",
formatted_body: '<a href="https://matrix.to/#/@bot2:matrix.org">Bot2</a>: hello',
},
userId: "@bot:matrix.org",
text: "Bot2: hello",
mentionRegexes: [],
});
expect(result.wasMentioned).toBe(false);
});
});
describe("regex patterns", () => {
it("detects mention via regex pattern in body text", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hey @bot can you help?",
},
userId,
text: "hey @bot can you help?",
mentionRegexes,
});
expect(result.wasMentioned).toBe(true);
});
});
describe("no mention", () => {
it("returns false when no mention is present", () => {
const result = resolveMentions({
content: {
msgtype: "m.text",
body: "hello world",
},
userId,
text: "hello world",
mentionRegexes,
});
expect(result.wasMentioned).toBe(false);
expect(result.hasExplicitMention).toBe(false);
});
});
});

View File

@@ -1,52 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { RoomMessageEventContent } from "./types.js";
/**
* Check if the formatted_body contains a matrix.to mention link for the given user ID.
* Many Matrix clients (including Element) use HTML links in formatted_body instead of
* or in addition to the m.mentions field.
*/
function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean {
if (!formattedBody || !userId) {
return false;
}
// Escape special regex characters in the user ID (e.g., @user:matrix.org)
const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match matrix.to links with the user ID, handling both URL-encoded and plain formats
// Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org"
const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i");
if (plainPattern.test(formattedBody)) {
return true;
}
// Also check URL-encoded version (@ -> %40, : -> %3A)
const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i");
return encodedPattern.test(formattedBody);
}
export function resolveMentions(params: {
content: RoomMessageEventContent;
userId?: string | null;
text?: string;
mentionRegexes: RegExp[];
}) {
const mentions = params.content["m.mentions"];
const mentionedUsers = Array.isArray(mentions?.user_ids)
? new Set(mentions.user_ids)
: new Set<string>();
// Check formatted_body for matrix.to mention links (legacy/alternative mention format)
const mentionedInFormattedBody = params.userId
? checkFormattedBodyMention(params.content.formatted_body, params.userId)
: false;
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
mentionedInFormattedBody ||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View File

@@ -1,132 +0,0 @@
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
vi.mock("../send.js", () => ({
sendMessageMatrix: (to: string, message: string, opts?: unknown) =>
sendMessageMatrixMock(to, message, opts),
}));
import { setMatrixRuntime } from "../../runtime.js";
import { deliverMatrixReplies } from "./replies.js";
describe("deliverMatrixReplies", () => {
const loadConfigMock = vi.fn(() => ({}));
const resolveMarkdownTableModeMock = vi.fn(() => "code");
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const resolveChunkModeMock = vi.fn(() => "length");
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]);
const runtimeStub = {
config: {
loadConfig: () => loadConfigMock(),
},
channel: {
text: {
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text),
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
const runtimeEnv: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]);
});
it("keeps replyToId on first reply only when replyToMode=first", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [
{ text: "first-a|first-b", replyToId: "reply-1" },
{ text: "second", replyToId: "reply-2" },
],
roomId: "room:1",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "first",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-1", threadId: undefined }),
);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: undefined }),
);
});
it("keeps replyToId on every reply when replyToMode=all", async () => {
await deliverMatrixReplies({
replies: [
{
text: "caption",
mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"],
replyToId: "reply-media",
audioAsVoice: true,
},
{ text: "plain", replyToId: "reply-text" },
],
roomId: "room:2",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3);
expect(sendMessageMatrixMock.mock.calls[0]).toEqual([
"room:2",
"caption",
expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[1]).toEqual([
"room:2",
"",
expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }),
]);
expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual(
expect.objectContaining({ replyToId: "reply-text" }),
);
});
it("suppresses replyToId when threadId is set", async () => {
chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
await deliverMatrixReplies({
replies: [{ text: "hello|thread", replyToId: "reply-thread" }],
roomId: "room:3",
client: {} as MatrixClient,
runtime: runtimeEnv,
textLimit: 4000,
replyToMode: "all",
threadId: "thread-77",
});
expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual(
expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }),
);
});
});

View File

@@ -1,100 +0,0 @@
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import { sendMessageMatrix } from "../send.js";
export async function deliverMatrixReplies(params: {
replies: ReplyPayload[];
roomId: string;
client: MatrixClient;
runtime: RuntimeEnv;
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const cfg = core.config.loadConfig();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId);
let hasReplied = false;
for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.("matrix reply missing text/media");
continue;
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
? [reply.mediaUrl]
: [];
const shouldIncludeReply = (id?: string) =>
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
if (mediaList.length === 0) {
let sentTextChunk = false;
for (const chunk of core.channel.text.chunkMarkdownTextWithMode(
text,
chunkLimit,
chunkMode,
)) {
const trimmed = chunk.trim();
if (!trimmed) {
continue;
}
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: replyToIdForReply,
threadId: params.threadId,
accountId: params.accountId,
});
sentTextChunk = true;
}
if (replyToIdForReply && !hasReplied && sentTextChunk) {
hasReplied = true;
}
continue;
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: replyToIdForReply,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
first = false;
}
if (replyToIdForReply && !hasReplied) {
hasReplied = true;
}
}
}

View File

@@ -1,65 +0,0 @@
import type { MatrixClient } from "../sdk.js";
export type MatrixRoomInfo = {
name?: string;
canonicalAlias?: string;
altAliases: string[];
};
export function createMatrixRoomInfoResolver(client: MatrixClient) {
const roomInfoCache = new Map<string, MatrixRoomInfo>();
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
const cached = roomInfoCache.get(roomId);
if (cached) {
return cached;
}
let name: string | undefined;
let canonicalAlias: string | undefined;
let altAliases: string[] = [];
try {
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
if (nameState && typeof nameState.name === "string") {
name = nameState.name;
}
} catch {
// ignore
}
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
if (aliasState && typeof aliasState.alias === "string") {
canonicalAlias = aliasState.alias;
}
const rawAliases = aliasState?.alt_aliases;
if (Array.isArray(rawAliases)) {
altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string");
}
} catch {
// ignore
}
const info = { name, canonicalAlias, altAliases };
roomInfoCache.set(roomId, info);
return info;
};
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
try {
const memberState = await client
.getRoomStateEvent(roomId, "m.room.member", userId)
.catch(() => null);
if (memberState && typeof memberState.displayname === "string") {
return memberState.displayname;
}
return userId;
} catch {
return userId;
}
};
return {
getRoomInfo,
getMemberDisplayName,
};
}

View File

@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveMatrixRoomConfig } from "./rooms.js";
describe("resolveMatrixRoomConfig", () => {
it("matches room IDs and aliases, not names", () => {
const rooms = {
"!room:example.org": { allow: true },
"#alias:example.org": { allow: true },
"Project Room": { allow: true },
};
const byId = resolveMatrixRoomConfig({
rooms,
roomId: "!room:example.org",
aliases: [],
name: "Project Room",
});
expect(byId.allowed).toBe(true);
expect(byId.matchKey).toBe("!room:example.org");
const byAlias = resolveMatrixRoomConfig({
rooms,
roomId: "!other:example.org",
aliases: ["#alias:example.org"],
name: "Other Room",
});
expect(byAlias.allowed).toBe(true);
expect(byAlias.matchKey).toBe("#alias:example.org");
const byName = resolveMatrixRoomConfig({
rooms: { "Project Room": { allow: true } },
roomId: "!different:example.org",
aliases: [],
name: "Project Room",
});
expect(byName.allowed).toBe(false);
expect(byName.config).toBeUndefined();
});
});

View File

@@ -1,47 +0,0 @@
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix-js";
import type { MatrixRoomConfig } from "../../types.js";
export type MatrixRoomConfigResolved = {
allowed: boolean;
allowlistConfigured: boolean;
config?: MatrixRoomConfig;
matchKey?: string;
matchSource?: "direct" | "wildcard";
};
export function resolveMatrixRoomConfig(params: {
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
aliases: string[];
name?: string | null;
}): MatrixRoomConfigResolved {
const rooms = params.rooms ?? {};
const keys = Object.keys(rooms);
const allowlistConfigured = keys.length > 0;
const candidates = buildChannelKeyCandidates(
params.roomId,
`room:${params.roomId}`,
...params.aliases,
);
const {
entry: matched,
key: matchedKey,
wildcardEntry,
wildcardKey,
} = resolveChannelEntryMatch({
entries: rooms,
keys: candidates,
wildcardKey: "*",
});
const resolved = matched ?? wildcardEntry;
const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false;
const matchKey = matchedKey ?? wildcardKey;
const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined;
return {
allowed,
allowlistConfigured,
config: resolved,
matchKey,
matchSource,
};
}

View File

@@ -1,48 +0,0 @@
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import { RelationType } from "./types.js";
export function resolveMatrixThreadTarget(params: {
threadReplies: "off" | "inbound" | "always";
messageId: string;
threadRootId?: string;
isThreadRoot?: boolean;
}): string | undefined {
const { threadReplies, messageId, threadRootId } = params;
if (threadReplies === "off") {
return undefined;
}
const isThreadRoot = params.isThreadRoot === true;
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
if (threadReplies === "inbound") {
return hasInboundThread ? threadRootId : undefined;
}
if (threadReplies === "always") {
return threadRootId ?? messageId;
}
return undefined;
}
export function resolveMatrixThreadRootId(params: {
event: MatrixRawEvent;
content: RoomMessageEventContent;
}): string | undefined {
const relates = params.content["m.relates_to"];
if (!relates || typeof relates !== "object") {
return undefined;
}
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
if ("event_id" in relates && typeof relates.event_id === "string") {
return relates.event_id;
}
if (
"m.in_reply_to" in relates &&
typeof relates["m.in_reply_to"] === "object" &&
relates["m.in_reply_to"] &&
"event_id" in relates["m.in_reply_to"] &&
typeof relates["m.in_reply_to"].event_id === "string"
) {
return relates["m.in_reply_to"].event_id;
}
}
return undefined;
}

View File

@@ -1,30 +0,0 @@
import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js";
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
export type { MatrixRawEvent } from "../sdk.js";
export const EventType = {
RoomMessage: "m.room.message",
RoomMessageEncrypted: "m.room.encrypted",
RoomMember: "m.room.member",
Location: "m.location",
Reaction: MATRIX_REACTION_EVENT_TYPE,
} as const;
export const RelationType = {
Replace: "m.replace",
Thread: "m.thread",
} as const;
export type RoomMessageEventContent = MessageEventContent & {
url?: string;
file?: EncryptedFile;
info?: {
mimetype?: string;
size?: number;
};
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};

View File

@@ -1,95 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildPollResponseContent,
buildPollStartContent,
parsePollStart,
parsePollStartContent,
} from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
it("preserves answer ids when parsing poll start content", () => {
const parsed = parsePollStart({
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Yes" },
{ id: "a2", "m.text": "No" },
],
},
});
expect(parsed).toMatchObject({
question: "Lunch?",
answers: [
{ id: "a1", text: "Yes" },
{ id: "a2", text: "No" },
],
maxSelections: 1,
});
});
it("caps invalid remote max selections to the available answer count", () => {
const parsed = parsePollStart({
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.undisclosed",
max_selections: 99,
answers: [
{ id: "a1", "m.text": "Yes" },
{ id: "a2", "m.text": "No" },
],
},
});
expect(parsed?.maxSelections).toBe(2);
});
});
describe("buildPollStartContent", () => {
it("preserves the requested multiselect cap instead of widening to all answers", () => {
const content = buildPollStartContent({
question: "Lunch?",
options: ["Pizza", "Sushi", "Tacos"],
maxSelections: 2,
});
expect(content["m.poll.start"]?.max_selections).toBe(2);
expect(content["m.poll.start"]?.kind).toBe("m.poll.undisclosed");
});
});
describe("buildPollResponseContent", () => {
it("builds a poll response payload with a reference relation", () => {
expect(buildPollResponseContent("$poll", ["a2"])).toEqual({
"m.poll.response": {
answers: ["a2"],
},
"org.matrix.msc3381.poll.response": {
answers: ["a2"],
},
"m.relates_to": {
rel_type: "m.reference",
event_id: "$poll",
},
});
});
});

View File

@@ -1,232 +0,0 @@
/**
* Matrix Poll Types (MSC3381)
*
* Defines types for Matrix poll events:
* - m.poll.start - Creates a new poll
* - m.poll.response - Records a vote
* - m.poll.end - Closes a poll
*/
import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/matrix-js";
export const M_POLL_START = "m.poll.start" as const;
export const M_POLL_RESPONSE = "m.poll.response" as const;
export const M_POLL_END = "m.poll.end" as const;
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const;
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const;
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const;
export const POLL_EVENT_TYPES = [
M_POLL_START,
M_POLL_RESPONSE,
M_POLL_END,
ORG_POLL_START,
ORG_POLL_RESPONSE,
ORG_POLL_END,
];
export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START];
export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE];
export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string;
};
export type PollAnswer = {
id: string;
} & TextContent;
export type PollParsedAnswer = {
id: string;
text: string;
};
export type PollStartSubtype = {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = {
[M_POLL_START]?: PollStartSubtype;
[ORG_POLL_START]?: PollStartSubtype;
"m.poll"?: PollStartSubtype;
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
};
export type PollSummary = {
eventId: string;
roomId: string;
sender: string;
senderName: string;
question: string;
answers: string[];
kind: PollKind;
maxSelections: number;
};
export type ParsedPollStart = {
question: string;
answers: PollParsedAnswer[];
kind: PollKind;
maxSelections: number;
};
export type PollResponseSubtype = {
answers: string[];
};
export type PollResponseContent = {
[M_POLL_RESPONSE]?: PollResponseSubtype;
[ORG_POLL_RESPONSE]?: PollResponseSubtype;
"m.relates_to": {
rel_type: "m.reference";
event_id: string;
};
};
export function isPollStartType(eventType: string): boolean {
return (POLL_START_TYPES as readonly string[]).includes(eventType);
}
export function getTextContent(text?: TextContent): string {
if (!text) {
return "";
}
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
}
export function parsePollStart(content: PollStartContent): ParsedPollStart | null {
const poll =
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) {
return null;
}
const question = getTextContent(poll.question).trim();
if (!question) {
return null;
}
const answers = poll.answers
.map((answer) => ({
id: answer.id,
text: getTextContent(answer).trim(),
}))
.filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0);
if (answers.length === 0) {
return null;
}
const maxSelectionsRaw = poll.max_selections;
const maxSelections =
typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw)
? Math.floor(maxSelectionsRaw)
: 1;
return {
question,
answers,
kind: poll.kind ?? "m.poll.disclosed",
maxSelections: Math.min(Math.max(maxSelections, 1), answers.length),
};
}
export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const parsed = parsePollStart(content);
if (!parsed) {
return null;
}
return {
eventId: "",
roomId: "",
sender: "",
senderName: "",
question: parsed.question,
answers: parsed.answers.map((answer) => answer.text),
kind: parsed.kind,
maxSelections: parsed.maxSelections,
};
}
export function formatPollAsText(summary: PollSummary): string {
const lines = [
"[Poll]",
summary.question,
"",
...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`),
];
return lines.join("\n");
}
function buildTextContent(body: string): TextContent {
return {
"m.text": body,
"org.matrix.msc1767.text": body,
};
}
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) {
return question;
}
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent {
const normalized = normalizePollInput(poll);
const answers = normalized.options.map((option, idx) => ({
id: `answer${idx + 1}`,
...buildTextContent(option),
}));
const isMultiple = normalized.maxSelections > 1;
const fallbackText = buildPollFallbackText(
normalized.question,
answers.map((answer) => getTextContent(answer)),
);
return {
[M_POLL_START]: {
question: buildTextContent(normalized.question),
kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: normalized.maxSelections,
answers,
},
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
};
}
export function buildPollResponseContent(
pollEventId: string,
answerIds: string[],
): PollResponseContent {
return {
[M_POLL_RESPONSE]: {
answers: answerIds,
},
[ORG_POLL_RESPONSE]: {
answers: answerIds,
},
"m.relates_to": {
rel_type: "m.reference",
event_id: pollEventId,
},
};
}

View File

@@ -1,70 +0,0 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix-js";
import { createMatrixClient, isBunRuntime } from "./client.js";
export type MatrixProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
userId?: string | null;
};
export async function probeMatrix(params: {
homeserver: string;
accessToken: string;
userId?: string;
timeoutMs: number;
}): Promise<MatrixProbe> {
const started = Date.now();
const result: MatrixProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
if (isBunRuntime()) {
return {
...result,
error: "Matrix probe requires Node (bun runtime not supported)",
elapsedMs: Date.now() - started,
};
}
if (!params.homeserver?.trim()) {
return {
...result,
error: "missing homeserver",
elapsedMs: Date.now() - started,
};
}
if (!params.accessToken?.trim()) {
return {
...result,
error: "missing access token",
elapsedMs: Date.now() - started,
};
}
try {
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
result.elapsedMs = Date.now() - started;
return result;
} catch (err) {
return {
...result,
status:
typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}

View File

@@ -1,346 +0,0 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
const resizeToJpegMock = vi.fn();
const runtimeStub = {
config: {
loadConfig: () => ({}),
},
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
resolveChunkMode: () => "length",
chunkMarkdownText: (text: string) => (text ? [text] : []),
chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll;
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue("evt1");
const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote");
const getEvent = vi.fn();
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const client = {
sendMessage,
sendEvent,
getEvent,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("./sdk.js").MatrixClient;
return { client, sendMessage, sendEvent, getEvent, uploadContent };
};
describe("sendMessageMatrix media", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
({ voteMatrixPoll } = await import("./actions/polls.js"));
});
beforeEach(() => {
loadWebMediaMock.mockReset().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
getImageMetadataMock.mockReset().mockResolvedValue(null);
resizeToJpegMock.mockReset();
setMatrixRuntime(runtimeStub);
});
it("uploads media with url payloads", async () => {
const { client, sendMessage, uploadContent } = makeClient();
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
msgtype?: string;
format?: string;
formatted_body?: string;
};
expect(content.msgtype).toBe("m.image");
expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file");
});
it("uploads encrypted media with file payloads", async () => {
const { client, sendMessage, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
expect(uploadArg?.toString()).toBe("encrypted");
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
file?: { url?: string };
};
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/file");
});
it("does not upload plaintext thumbnails for encrypted image sends", async () => {
const { client, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(1);
});
it("uploads thumbnail metadata for unencrypted large images", async () => {
const { client, sendMessage, uploadContent } = makeClient();
getImageMetadataMock
.mockResolvedValueOnce({ width: 1600, height: 1200 })
.mockResolvedValueOnce({ width: 800, height: 600 });
resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb"));
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(uploadContent).toHaveBeenCalledTimes(2);
const content = sendMessage.mock.calls[0]?.[1] as {
info?: {
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
};
expect(content.info?.thumbnail_url).toBe("mxc://example/file");
expect(content.info?.thumbnail_info).toMatchObject({
w: 800,
h: 600,
mimetype: "image/jpeg",
size: Buffer.from("thumb").byteLength,
});
});
});
describe("sendMessageMatrix threads", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
({ voteMatrixPoll } = await import("./actions/polls.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("includes thread relation metadata when threadId is set", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello thread", {
client,
threadId: "$thread",
});
const content = sendMessage.mock.calls[0]?.[1] as {
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
expect(content["m.relates_to"]).toMatchObject({
rel_type: "m.thread",
event_id: "$thread",
"m.in_reply_to": { event_id: "$thread" },
});
});
});
describe("voteMatrixPoll", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ voteMatrixPoll } = await import("./actions/polls.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("maps 1-based option indexes to Matrix poll answer ids", async () => {
const { client, getEvent, sendEvent } = makeClient();
getEvent.mockResolvedValue({
type: "m.poll.start",
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
});
const result = await voteMatrixPoll("room:!room:example", "$poll", {
client,
optionIndex: 2,
});
expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", {
"m.poll.response": { answers: ["a2"] },
"org.matrix.msc3381.poll.response": { answers: ["a2"] },
"m.relates_to": {
rel_type: "m.reference",
event_id: "$poll",
},
});
expect(result).toMatchObject({
eventId: "evt-poll-vote",
roomId: "!room:example",
pollId: "$poll",
answerIds: ["a2"],
labels: ["Sushi"],
});
});
it("rejects out-of-range option indexes", async () => {
const { client, getEvent } = makeClient();
getEvent.mockResolvedValue({
type: "m.poll.start",
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
max_selections: 1,
answers: [{ id: "a1", "m.text": "Pizza" }],
},
},
});
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
optionIndex: 2,
}),
).rejects.toThrow("out of range");
});
it("rejects votes that exceed the poll selection cap", async () => {
const { client, getEvent } = makeClient();
getEvent.mockResolvedValue({
type: "m.poll.start",
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
});
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
optionIndexes: [1, 2],
}),
).rejects.toThrow("at most 1 selection");
});
it("rejects non-poll events before sending a response", async () => {
const { client, getEvent, sendEvent } = makeClient();
getEvent.mockResolvedValue({
type: "m.room.message",
content: { body: "hello" },
});
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
optionIndex: 1,
}),
).rejects.toThrow("is not a Matrix poll start event");
expect(sendEvent).not.toHaveBeenCalled();
});
});

View File

@@ -1,275 +0,0 @@
import type { PollInput } from "openclaw/plugin-sdk/matrix-js";
import { getMatrixRuntime } from "../runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import { buildMatrixReactionContent } from "./reaction-common.js";
import type { MatrixClient } from "./sdk.js";
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
import {
buildReplyRelation,
buildTextContent,
buildThreadRelation,
resolveMatrixMsgType,
resolveMatrixVoiceDecision,
} from "./send/formatting.js";
import {
buildMediaContent,
prepareImageInfo,
resolveMediaDurationMs,
uploadMediaMaybeEncrypted,
} from "./send/media.js";
import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js";
import {
EventType,
MsgType,
type MatrixOutboundContent,
type MatrixSendOpts,
type MatrixSendResult,
} from "./send/types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
export { resolveMatrixRoomId } from "./send/targets.js";
type MatrixClientResolveOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
function normalizeMatrixClientResolveOpts(
opts?: MatrixClient | MatrixClientResolveOpts,
): MatrixClientResolveOpts {
if (!opts) {
return {};
}
if (typeof (opts as MatrixClient).sendEvent === "function") {
return { client: opts as MatrixClient };
}
return opts;
}
export async function sendMessageMatrix(
to: string,
message: string,
opts: MatrixSendOpts = {},
): Promise<MatrixSendResult> {
const trimmedMessage = message?.trim() ?? "";
if (!trimmedMessage && !opts.mediaUrl) {
throw new Error("Matrix send requires text or media");
}
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix-js",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix-js");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix-js", opts.accountId);
const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
convertedMessage,
chunkLimit,
chunkMode,
);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(opts.accountId);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,
filename: media.fileName,
});
const durationMs = await resolveMediaDurationMs({
buffer: media.buffer,
contentType: media.contentType,
fileName: media.fileName,
kind: media.kind,
});
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage
? await prepareImageInfo({
buffer: media.buffer,
client,
encrypted: Boolean(uploaded.file),
})
: undefined;
const [firstChunk, ...rest] = chunks;
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({
msgtype,
body,
url: uploaded.url,
file: uploaded.file,
filename: media.fileName,
mimetype: media.contentType,
size: media.buffer.byteLength,
durationMs,
relation,
isVoice: useVoice,
imageInfo,
});
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
const followupRelation = threadId ? relation : undefined;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) {
continue;
}
const followup = buildTextContent(text, followupRelation);
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim();
if (!text) {
continue;
}
const content = buildTextContent(text, relation);
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
}
}
return {
messageId: lastMessageId || "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function sendPollMatrix(
to: string,
poll: PollInput,
opts: MatrixSendOpts = {},
): Promise<{ eventId: string; roomId: string }> {
if (!poll.question?.trim()) {
throw new Error("Matrix poll requires a question");
}
if (!poll.options?.length) {
throw new Error("Matrix poll requires options");
}
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId);
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
eventId: eventId ?? "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stop();
}
}
}
export async function sendTypingMatrix(
roomId: string,
typing: boolean,
timeoutMs?: number,
client?: MatrixClient,
): Promise<void> {
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
timeoutMs,
});
try {
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}
export async function sendReadReceiptMatrix(
roomId: string,
eventId: string,
client?: MatrixClient,
): Promise<void> {
if (!eventId?.trim()) {
return;
}
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
});
try {
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}
export async function reactMatrixMessage(
roomId: string,
messageId: string,
emoji: string,
opts?: MatrixClient | MatrixClientResolveOpts,
): Promise<void> {
const clientOpts = normalizeMatrixClientResolveOpts(opts);
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client: clientOpts.client,
timeoutMs: clientOpts.timeoutMs,
accountId: clientOpts.accountId ?? undefined,
});
try {
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
const reaction = buildMatrixReactionContent(messageId, emoji);
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}

View File

@@ -1,53 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../accounts.js";
import { getActiveMatrixClient } from "../active-client.js";
import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
const getCore = () => getMatrixRuntime();
export function ensureNodeRuntime() {
if (isBunRuntime()) {
throw new Error("Matrix support requires Node (bun runtime not supported)");
}
}
export function resolveMediaMaxBytes(accountId?: string | null): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig;
const matrixCfg = resolveMatrixAccountConfig({ cfg, accountId });
const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined;
if (typeof mediaMaxMb === "number") {
return mediaMaxMb * 1024 * 1024;
}
return undefined;
}
export async function resolveMatrixClient(opts: {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(opts.accountId);
if (active) {
return { client: active, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId: opts.accountId });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
password: auth.password,
deviceId: auth.deviceId,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
autoBootstrapCrypto: false,
});
await client.prepareForOneOff();
return { client, stopOnDone: true };
}

View File

@@ -1,93 +0,0 @@
import { getMatrixRuntime } from "../../runtime.js";
import { markdownToMatrixHtml } from "../format.js";
import {
MsgType,
RelationType,
type MatrixFormattedContent,
type MatrixMediaMsgType,
type MatrixRelation,
type MatrixReplyRelation,
type MatrixTextContent,
type MatrixThreadRelation,
} from "./types.js";
const getCore = () => getMatrixRuntime();
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
body,
"m.relates_to": relation,
}
: {
msgtype: MsgType.Text,
body,
};
applyMatrixFormatting(content, body);
return content;
}
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) {
return;
}
content.format = "org.matrix.custom.html";
content.formatted_body = formatted;
}
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
const trimmed = replyToId?.trim();
if (!trimmed) {
return undefined;
}
return { "m.in_reply_to": { event_id: trimmed } };
}
export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
const trimmed = threadId.trim();
return {
rel_type: RelationType.Thread,
event_id: trimmed,
is_falling_back: true,
"m.in_reply_to": { event_id: replyToId?.trim() || trimmed },
};
}
export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType {
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
export function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) {
return { useVoice: false };
}
if (isMatrixVoiceCompatibleAudio(opts)) {
return { useVoice: true };
}
return { useVoice: false };
}
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
// Matrix currently shares the core voice compatibility policy.
// Keep this wrapper as the seam if Matrix policy diverges later.
return getCore().media.isVoiceCompatibleAudio({
contentType: opts.contentType,
fileName: opts.fileName,
});
}

View File

@@ -1,234 +0,0 @@
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "../sdk.js";
import { applyMatrixFormatting } from "./formatting.js";
import {
type MatrixMediaContent,
type MatrixMediaInfo,
type MatrixMediaMsgType,
type MatrixRelation,
type MediaKind,
} from "./types.js";
const getCore = () => getMatrixRuntime();
export function buildMatrixMediaInfo(params: {
size: number;
mimetype?: string;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
}): MatrixMediaInfo | undefined {
const base: FileWithThumbnailInfo = {};
if (Number.isFinite(params.size)) {
base.size = params.size;
}
if (params.mimetype) {
base.mimetype = params.mimetype;
}
if (params.imageInfo) {
const dimensional: DimensionalFileInfo = {
...base,
...params.imageInfo,
};
if (typeof params.durationMs === "number") {
const videoInfo: VideoFileInfo = {
...dimensional,
duration: params.durationMs,
};
return videoInfo;
}
return dimensional;
}
if (typeof params.durationMs === "number") {
const timedInfo: TimedFileInfo = {
...base,
duration: params.durationMs,
};
return timedInfo;
}
if (Object.keys(base).length === 0) {
return undefined;
}
return base;
}
export function buildMediaContent(params: {
msgtype: MatrixMediaMsgType;
body: string;
url?: string;
filename?: string;
mimetype?: string;
size: number;
relation?: MatrixRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
file?: EncryptedFile;
}): MatrixMediaContent {
const info = buildMatrixMediaInfo({
size: params.size,
mimetype: params.mimetype,
durationMs: params.durationMs,
imageInfo: params.imageInfo,
});
const base: MatrixMediaContent = {
msgtype: params.msgtype,
body: params.body,
filename: params.filename,
info: info ?? undefined,
};
// Encrypted media should only include the "file" payload, not top-level "url".
if (!params.file && params.url) {
base.url = params.url;
}
// For encrypted files, add the file object
if (params.file) {
base.file = params.file;
}
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
if (typeof params.durationMs === "number") {
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
applyMatrixFormatting(base, params.body);
return base;
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
export async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
encrypted?: boolean;
}): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore()
.media.getImageMetadata(params.buffer)
.catch(() => null);
if (!meta) {
return undefined;
}
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
if (params.encrypted) {
// For E2EE media, avoid uploading plaintext thumbnails.
return imageInfo;
}
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await getCore().media.resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getCore()
.media.getImageMetadata(thumbBuffer)
.catch(() => null);
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
export async function resolveMediaDurationMs(params: {
buffer: Buffer;
contentType?: string;
fileName?: string;
kind: MediaKind;
}): Promise<number | undefined> {
if (params.kind !== "audio" && params.kind !== "video") {
return undefined;
}
try {
const fileInfo: IFileInfo | string | undefined =
params.contentType || params.fileName
? {
mimeType: params.contentType,
size: params.buffer.byteLength,
path: params.fileName,
}
: undefined;
const metadata = await parseBuffer(params.buffer, fileInfo, {
duration: true,
skipCovers: true,
});
const durationSeconds = metadata.format.duration;
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
return Math.max(0, Math.round(durationSeconds * 1000));
}
} catch {
// Duration is optional; ignore parse failures.
}
return undefined;
}
async function uploadFile(
client: MatrixClient,
file: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<string> {
return await client.uploadContent(file, params.contentType, params.filename);
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
export async function uploadMediaMaybeEncrypted(
client: MatrixClient,
roomId: string,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId));
if (isEncrypted && client.crypto) {
// Encrypt the media before uploading
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
// Upload unencrypted
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
beforeEach(async () => {
vi.resetModules();
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
});
describe("resolveMatrixRoomId", () => {
it("uses m.direct when available", async () => {
const userId = "@user:example.org";
const client = {
getAccountData: vi.fn().mockResolvedValue({
[userId]: ["!room:example.org"],
}),
getJoinedRooms: vi.fn(),
getJoinedRoomMembers: vi.fn(),
setAccountData: vi.fn(),
} as unknown as MatrixClient;
const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org");
// oxlint-disable-next-line typescript/unbound-method
expect(client.getJoinedRooms).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("falls back to joined rooms and persists m.direct", async () => {
const userId = "@fallback:example.org";
const roomId = "!room:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]),
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({ [userId]: [roomId] }),
);
});
it("continues when a room member lookup fails", async () => {
const userId = "@continue:example.org";
const roomId = "!good:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const getJoinedRoomMembers = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(["@bot:example.org", userId]);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
getJoinedRoomMembers,
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalled();
});
it("allows larger rooms when no 1:1 match exists", async () => {
const userId = "@group:example.org";
const roomId = "!group:example.org";
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi
.fn()
.mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]),
setAccountData: vi.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
});
});
describe("normalizeThreadId", () => {
it("returns null for empty thread ids", () => {
expect(normalizeThreadId(" ")).toBeNull();
expect(normalizeThreadId("$thread")).toBe("$thread");
});
});

View File

@@ -1,152 +0,0 @@
import type { MatrixClient } from "../sdk.js";
import { EventType, type MatrixDirectAccountData } from "./types.js";
function normalizeTarget(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Matrix target is required (room:<id> or #alias)");
}
return trimmed;
}
export function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
// Size-capped to prevent unbounded growth (#4948)
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
const directRoomCache = new Map<string, string>();
function setDirectRoomCached(key: string, value: string): void {
directRoomCache.set(key, value);
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
const oldest = directRoomCache.keys().next().value;
if (oldest !== undefined) {
directRoomCache.delete(oldest);
}
}
}
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | undefined;
try {
directContent = (await client.getAccountData(EventType.Direct)) as
| MatrixDirectAccountData
| undefined;
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) {
return;
}
const next = [roomId, ...current.filter((id) => id !== roomId)];
try {
await client.setAccountData(EventType.Direct, {
...existing,
[userId]: next,
});
} catch {
// Ignore persistence errors.
}
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim();
if (!trimmed.startsWith("@")) {
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
}
const cached = directRoomCache.get(trimmed);
if (cached) {
return cached;
}
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try {
const directContent = (await client.getAccountData(EventType.Direct)) as Record<
string,
string[] | undefined
>;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list && list.length > 0) {
setDirectRoomCached(trimmed, list[0]);
return list[0];
}
} catch {
// Ignore and fall back.
}
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
let fallbackRoom: string | null = null;
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
let members: string[];
try {
members = await client.getJoinedRoomMembers(roomId);
} catch {
continue;
}
if (!members.includes(trimmed)) {
continue;
}
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
setDirectRoomCached(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
setDirectRoomCached(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
}
export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise<string> {
const target = normalizeTarget(raw);
const lowered = target.toLowerCase();
if (lowered.startsWith("matrix:")) {
return await resolveMatrixRoomId(client, target.slice("matrix:".length));
}
if (lowered.startsWith("room:")) {
return await resolveMatrixRoomId(client, target.slice("room:".length));
}
if (lowered.startsWith("channel:")) {
return await resolveMatrixRoomId(client, target.slice("channel:".length));
}
if (lowered.startsWith("user:")) {
return await resolveDirectRoomId(client, target.slice("user:".length));
}
if (target.startsWith("@")) {
return await resolveDirectRoomId(client, target);
}
if (target.startsWith("#")) {
const resolved = await client.resolveRoom(target);
if (!resolved) {
throw new Error(`Matrix alias ${target} could not be resolved`);
}
return resolved;
}
return target;
}

View File

@@ -1,108 +0,0 @@
import {
MATRIX_ANNOTATION_RELATION_TYPE,
MATRIX_REACTION_EVENT_TYPE,
type MatrixReactionEventContent,
} from "../reaction-common.js";
import type {
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MessageEventContent,
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "../sdk.js";
// Message types
export const MsgType = {
Text: "m.text",
Image: "m.image",
Audio: "m.audio",
Video: "m.video",
File: "m.file",
Notice: "m.notice",
} as const;
// Relation types
export const RelationType = {
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
Replace: "m.replace",
Thread: "m.thread",
} as const;
// Event types
export const EventType = {
Direct: "m.direct",
Reaction: MATRIX_REACTION_EVENT_TYPE,
RoomMessage: "m.room.message",
} as const;
export type MatrixDirectAccountData = Record<string, string[]>;
export type MatrixReplyRelation = {
"m.in_reply_to": { event_id: string };
};
export type MatrixThreadRelation = {
rel_type: typeof RelationType.Thread;
event_id: string;
is_falling_back?: boolean;
"m.in_reply_to"?: { event_id: string };
};
export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation;
export type MatrixReplyMeta = {
"m.relates_to"?: MatrixRelation;
};
export type MatrixMediaInfo =
| FileWithThumbnailInfo
| DimensionalFileInfo
| TimedFileInfo
| VideoFileInfo;
export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta;
export type MatrixMediaContent = MessageEventContent &
MatrixReplyMeta & {
info?: MatrixMediaInfo;
url?: string;
file?: EncryptedFile;
filename?: string;
"org.matrix.msc3245.voice"?: Record<string, never>;
"org.matrix.msc1767.audio"?: { duration: number };
};
export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
export type ReactionEventContent = MatrixReactionEventContent;
export type MatrixSendResult = {
messageId: string;
roomId: string;
};
export type MatrixSendOpts = {
client?: import("../sdk.js").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
};
export type MatrixMediaMsgType =
| typeof MsgType.Image
| typeof MsgType.Audio
| typeof MsgType.Video
| typeof MsgType.File;
export type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
export type MatrixFormattedContent = MessageEventContent & {
format?: string;
formatted_body?: string;
};

View File

@@ -1,563 +0,0 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { DmPolicy } from "openclaw/plugin-sdk/matrix-js";
import {
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
type RuntimeEnv,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk/matrix-js";
import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import {
listMatrixAccountIds,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
resolveMatrixAccountConfig,
} from "./matrix/accounts.js";
import {
getMatrixScopedEnvVarNames,
hasReadyMatrixEnvAuth,
resolveScopedMatrixEnvConfig,
} from "./matrix/client.js";
import { updateMatrixAccountConfig } from "./matrix/config-update.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix-js" as const;
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
const allowFrom =
policy === "open"
? addWildcardAllowFrom(cfg.channels?.["matrix-js"]?.dm?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
dm: {
...cfg.channels?.["matrix-js"]?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Matrix requires a homeserver URL.",
"Use an access token (recommended) or password login to an existing account.",
"With access token: user ID is fetched automatically.",
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD, MATRIX_DEVICE_ID, MATRIX_DEVICE_NAME.",
"Per-account env vars: MATRIX_<ACCOUNT_ID>_HOMESERVER, MATRIX_<ACCOUNT_ID>_USER_ID, MATRIX_<ACCOUNT_ID>_ACCESS_TOKEN, MATRIX_<ACCOUNT_ID>_PASSWORD, MATRIX_<ACCOUNT_ID>_DEVICE_ID, MATRIX_<ACCOUNT_ID>_DEVICE_NAME.",
`Docs: ${formatDocsLink("/channels/matrix-js", "channels/matrix-js")}`,
].join("\n"),
"Matrix setup",
);
}
async function promptMatrixAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.["matrix-js"]?.dm?.allowFrom ?? [];
const account = resolveMatrixAccount({ cfg });
const canResolve = Boolean(account.configured);
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
while (true) {
const entry = await prompter.text({
message: "Matrix allowFrom (full @user:server; display name only if unique)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedIds: string[] = [];
const pending: string[] = [];
const unresolved: string[] = [];
const unresolvedNotes: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
resolvedIds.push(part);
continue;
}
if (!canResolve) {
unresolved.push(part);
continue;
}
pending.push(part);
}
if (pending.length > 0) {
const results = await resolveMatrixTargets({
cfg,
inputs: pending,
kind: "user",
}).catch(() => []);
for (const result of results) {
if (result?.resolved && result.id) {
resolvedIds.push(result.id);
continue;
}
if (result?.input) {
unresolved.push(result.input);
if (result.note) {
unresolvedNotes.push(`${result.input}: ${result.note}`);
}
}
}
}
if (unresolved.length > 0) {
const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
await prompter.note(
`Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
"Matrix allowlist",
);
continue;
}
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
enabled: true,
dm: {
...cfg.channels?.["matrix-js"]?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
},
};
}
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
enabled: true,
groupPolicy,
},
},
};
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
...cfg.channels,
"matrix-js": {
...cfg.channels?.["matrix-js"],
enabled: true,
groups,
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Matrix",
channel,
policyKey: "channels.matrix-js.dm.policy",
allowFromKey: "channels.matrix-js.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.["matrix-js"]?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
};
type MatrixConfigureIntent = "update" | "add-account";
async function runMatrixConfigure(params: {
cfg: CoreConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
forceAllowFrom: boolean;
accountOverrides?: Partial<Record<string, string>>;
shouldPromptAccountIds?: boolean;
intent: MatrixConfigureIntent;
}): Promise<{ cfg: CoreConfig; accountId: string }> {
let next = migrateMatrixLegacyCredentialsToDefaultAccount(params.cfg);
await ensureMatrixSdkInstalled({
runtime: params.runtime,
confirm: async (message) =>
await params.prompter.confirm({
message,
initialValue: true,
}),
});
const defaultAccountId = resolveDefaultMatrixAccountId(next);
let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID;
if (params.intent === "add-account") {
const enteredName = String(
await params.prompter.text({
message: "Matrix account name",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
accountId = normalizeAccountId(enteredName);
if (enteredName !== accountId) {
await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account");
}
next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true });
} else {
const override = params.accountOverrides?.[channel]?.trim();
if (override) {
accountId = normalizeAccountId(override);
} else if (params.shouldPromptAccountIds) {
accountId = await promptAccountId({
cfg: next,
prompter: params.prompter,
label: "Matrix-js",
currentId: accountId,
listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig),
defaultAccountId,
});
}
}
const existing = resolveMatrixAccountConfig({ cfg: next, accountId });
const account = resolveMatrixAccount({ cfg: next, accountId });
if (!account.configured) {
await noteMatrixAuthHelp(params.prompter);
}
const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env);
const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env);
const globalEnv = {
homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "",
userId: process.env.MATRIX_USER_ID?.trim() ?? "",
accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined,
password: process.env.MATRIX_PASSWORD?.trim() || undefined,
};
const scopedReady = hasReadyMatrixEnvAuth(scopedEnv);
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv);
const globalReady = hasReadyMatrixEnvAuth(globalEnv);
const envReady =
scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady));
const envHomeserver =
scopedEnv.homeserver ||
(accountId === DEFAULT_ACCOUNT_ID
? defaultScopedEnv.homeserver || globalEnv.homeserver
: undefined);
const envUserId =
scopedEnv.userId ||
(accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined);
if (
envReady &&
!existing.homeserver &&
!existing.userId &&
!existing.accessToken &&
!existing.password
) {
const scopedEnvNames = getMatrixScopedEnvVarNames(accountId);
const envSourceHint =
accountId === DEFAULT_ACCOUNT_ID
? "MATRIX_* or MATRIX_DEFAULT_*"
: `${scopedEnvNames.homeserver} (+ auth vars)`;
const useEnv = await params.prompter.confirm({
message: `Matrix env vars detected (${envSourceHint}). Use env values?`,
initialValue: true,
});
if (useEnv) {
next = updateMatrixAccountConfig(next, accountId, { enabled: true });
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter });
}
return { cfg: next, accountId };
}
}
const homeserver = String(
await params.prompter.text({
message: "Matrix homeserver URL",
initialValue: existing.homeserver ?? envHomeserver,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
if (!/^https?:\/\//i.test(raw)) {
return "Use a full URL (https://...)";
}
return undefined;
},
}),
).trim();
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let userId = existing.userId ?? "";
if (accessToken || password) {
const keep = await params.prompter.confirm({
message: "Matrix credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
accessToken = "";
password = "";
userId = "";
}
}
if (!accessToken && !password) {
const authMode = await params.prompter.select({
message: "Matrix auth method",
options: [
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
],
});
if (authMode === "token") {
accessToken = String(
await params.prompter.text({
message: "Matrix access token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
userId = "";
} else {
userId = String(
await params.prompter.text({
message: "Matrix user ID",
initialValue: existing.userId ?? envUserId,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) {
return "Required";
}
if (!raw.startsWith("@")) {
return "Matrix user IDs should start with @";
}
if (!raw.includes(":")) {
return "Matrix user IDs should include a server (:server)";
}
return undefined;
},
}),
).trim();
password = String(
await params.prompter.text({
message: "Matrix password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
}
const deviceName = String(
await params.prompter.text({
message: "Matrix device name (optional)",
initialValue: existing.deviceName ?? "OpenClaw Gateway",
}),
).trim();
const enableEncryption = await params.prompter.confirm({
message: "Enable end-to-end encryption (E2EE)?",
initialValue: existing.encryption ?? false,
});
next = updateMatrixAccountConfig(next, accountId, {
enabled: true,
homeserver,
userId: userId || null,
accessToken: accessToken || null,
password: password || null,
deviceName: deviceName || null,
encryption: enableEncryption,
});
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter });
}
const existingGroups =
next.channels?.["matrix-js"]?.groups ?? next.channels?.["matrix-js"]?.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter: params.prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.["matrix-js"]?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await params.prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Matrix rooms",
);
}
} catch (err) {
await params.prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixGroupRooms(next, roomKeys);
}
}
return { cfg: next, accountId };
}
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
const configured = account.configured;
const sdkReady = isMatrixSdkAvailable();
return {
channel,
configured,
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth",
};
},
configure: async ({
cfg,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
}) =>
await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
intent: "update",
}),
configureInteractive: async ({
cfg,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
configured,
}) => {
if (!configured) {
return await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
intent: "update",
});
}
const action = await prompter.select({
message: "Matrix-js already configured. What do you want to do?",
options: [
{ value: "update", label: "Modify settings" },
{ value: "add-account", label: "Add account" },
{ value: "skip", label: "Skip (leave as-is)" },
],
initialValue: "update",
});
if (action === "skip") {
return "skip";
}
return await runMatrixConfigure({
cfg: cfg as CoreConfig,
runtime,
prompter,
forceAllowFrom,
accountOverrides,
shouldPromptAccountIds,
intent: action === "add-account" ? "add-account" : "update",
});
},
dmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {
...(cfg as CoreConfig).channels,
"matrix-js": { ...(cfg as CoreConfig).channels?.["matrix-js"], enabled: false },
},
}),
};

View File

@@ -1,55 +0,0 @@
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix-js";
import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js";
import { getMatrixRuntime } from "./runtime.js";
export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix-js",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
mediaUrl,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix-js",
messageId: result.messageId,
roomId: result.roomId,
};
},
sendPoll: async ({ to, poll, threadId, accountId }) => {
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await sendPollMatrix(to, poll, {
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});
return {
channel: "matrix-js",
messageId: result.eventId,
roomId: result.roomId,
pollId: result.eventId,
};
},
};

View File

@@ -1,92 +0,0 @@
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
vi.mock("./directory-live.js", () => ({
listMatrixDirectoryPeersLive: vi.fn(),
listMatrixDirectoryGroupsLive: vi.fn(),
}));
describe("resolveMatrixTargets (users)", () => {
beforeEach(() => {
vi.mocked(listMatrixDirectoryPeersLive).mockReset();
vi.mocked(listMatrixDirectoryGroupsLive).mockReset();
});
it("resolves exact unique display name matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("@alice:example.org");
});
it("does not resolve ambiguous or non-exact matches", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "user", id: "@alice:example.org", name: "Alice" },
{ kind: "user", id: "@alice:evil.example", name: "Alice" },
];
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice"],
kind: "user",
});
expect(result?.resolved).toBe(false);
expect(result?.note).toMatch(/use full Matrix ID/i);
});
it("prefers exact group matches over first partial result", async () => {
const matches: ChannelDirectoryEntry[] = [
{ kind: "group", id: "!one:example.org", name: "General", handle: "#general" },
{ kind: "group", id: "!two:example.org", name: "Team", handle: "#team" },
];
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue(matches);
const [result] = await resolveMatrixTargets({
cfg: {},
inputs: ["#team"],
kind: "group",
});
expect(result?.resolved).toBe(true);
expect(result?.id).toBe("!two:example.org");
expect(result?.note).toBe("multiple matches; chose first");
});
it("reuses directory lookups for duplicate inputs", async () => {
vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([
{ kind: "user", id: "@alice:example.org", name: "Alice" },
]);
vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([
{ kind: "group", id: "!team:example.org", name: "Team", handle: "#team" },
]);
const userResults = await resolveMatrixTargets({
cfg: {},
inputs: ["Alice", "Alice"],
kind: "user",
});
const groupResults = await resolveMatrixTargets({
cfg: {},
inputs: ["#team", "#team"],
kind: "group",
});
expect(userResults.every((entry) => entry.resolved)).toBe(true);
expect(groupResults.every((entry) => entry.resolved)).toBe(true);
expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1);
expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,149 +0,0 @@
import type {
ChannelDirectoryEntry,
ChannelResolveKind,
ChannelResolveResult,
RuntimeEnv,
} from "openclaw/plugin-sdk/matrix-js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
function findExactDirectoryMatches(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry[] {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return [];
}
return matches.filter((match) => {
const id = match.id.trim().toLowerCase();
const name = match.name?.trim().toLowerCase();
const handle = match.handle?.trim().toLowerCase();
return normalized === id || normalized === name || normalized === handle;
});
}
function pickBestGroupMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) {
return undefined;
}
const [exact] = findExactDirectoryMatches(matches, query);
return exact ?? matches[0];
}
function pickBestUserMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) {
return undefined;
}
const exact = findExactDirectoryMatches(matches, query);
if (exact.length === 1) {
return exact[0];
}
return undefined;
}
function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string {
if (matches.length === 0) {
return "no matches";
}
const normalized = query.trim().toLowerCase();
if (!normalized) {
return "empty input";
}
const exact = findExactDirectoryMatches(matches, normalized);
if (exact.length === 0) {
return "no exact match; use full Matrix ID";
}
if (exact.length > 1) {
return "multiple exact matches; use full Matrix ID";
}
return "no exact match; use full Matrix ID";
}
export async function resolveMatrixTargets(params: {
cfg: unknown;
inputs: string[];
kind: ChannelResolveKind;
runtime?: RuntimeEnv;
}): Promise<ChannelResolveResult[]> {
const results: ChannelResolveResult[] = [];
const userLookupCache = new Map<string, ChannelDirectoryEntry[]>();
const groupLookupCache = new Map<string, ChannelDirectoryEntry[]>();
const readUserMatches = async (query: string): Promise<ChannelDirectoryEntry[]> => {
const cached = userLookupCache.get(query);
if (cached) {
return cached;
}
const matches = await listMatrixDirectoryPeersLive({
cfg: params.cfg,
query,
limit: 5,
});
userLookupCache.set(query, matches);
return matches;
};
const readGroupMatches = async (query: string): Promise<ChannelDirectoryEntry[]> => {
const cached = groupLookupCache.get(query);
if (cached) {
return cached;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: params.cfg,
query,
limit: 5,
});
groupLookupCache.set(query, matches);
return matches;
};
for (const input of params.inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (params.kind === "user") {
if (trimmed.startsWith("@") && trimmed.includes(":")) {
results.push({ input, resolved: true, id: trimmed });
continue;
}
try {
const matches = await readUserMatches(trimmed);
const best = pickBestUserMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: best ? undefined : describeUserMatchFailure(matches, trimmed),
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
continue;
}
try {
const matches = await readGroupMatches(trimmed);
const best = pickBestGroupMatch(matches, trimmed);
results.push({
input,
resolved: Boolean(best?.id),
id: best?.id,
name: best?.name,
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
});
} catch (err) {
params.runtime?.error?.(`matrix resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
}

View File

@@ -1,14 +0,0 @@
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js";
let runtime: PluginRuntime | null = null;
export function setMatrixRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMatrixRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Matrix runtime not initialized");
}
return runtime;
}

View File

@@ -1,436 +0,0 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/matrix-js";
import {
bootstrapMatrixVerification,
acceptMatrixVerification,
cancelMatrixVerification,
confirmMatrixVerificationReciprocateQr,
confirmMatrixVerificationSas,
deleteMatrixMessage,
editMatrixMessage,
generateMatrixVerificationQr,
getMatrixEncryptionStatus,
getMatrixRoomKeyBackupStatus,
getMatrixVerificationStatus,
getMatrixMemberInfo,
getMatrixRoomInfo,
getMatrixVerificationSas,
listMatrixPins,
listMatrixReactions,
listMatrixVerifications,
mismatchMatrixVerificationSas,
pinMatrixMessage,
readMatrixMessages,
requestMatrixVerification,
restoreMatrixRoomKeyBackup,
removeMatrixReactions,
scanMatrixVerificationQr,
sendMatrixMessage,
startMatrixVerification,
unpinMatrixMessage,
voteMatrixPoll,
verifyMatrixRecoveryKey,
} from "./matrix/actions.js";
import { reactMatrixMessage } from "./matrix/send.js";
import type { CoreConfig } from "./types.js";
const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
const reactionActions = new Set(["react", "reactions"]);
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
const pollActions = new Set(["pollVote"]);
const verificationActions = new Set([
"encryptionStatus",
"verificationList",
"verificationRequest",
"verificationAccept",
"verificationCancel",
"verificationStart",
"verificationGenerateQr",
"verificationScanQr",
"verificationSas",
"verificationConfirm",
"verificationMismatch",
"verificationConfirmQr",
"verificationStatus",
"verificationBootstrap",
"verificationRecoveryKey",
"verificationBackupStatus",
"verificationBackupRestore",
]);
function readRoomId(params: Record<string, unknown>, required = true): string {
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
if (direct) {
return direct;
}
if (!required) {
return readStringParam(params, "to") ?? "";
}
return readStringParam(params, "to", { required: true });
}
function toSnakeCaseKey(key: string): string {
return key
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase();
}
function readRawParam(params: Record<string, unknown>, key: string): unknown {
if (Object.hasOwn(params, key)) {
return params[key];
}
const snakeKey = toSnakeCaseKey(key);
if (snakeKey !== key && Object.hasOwn(params, snakeKey)) {
return params[snakeKey];
}
return undefined;
}
function readNumericArrayParam(
params: Record<string, unknown>,
key: string,
options: { integer?: boolean } = {},
): number[] {
const { integer = false } = options;
const raw = readRawParam(params, key);
if (raw === undefined) {
return [];
}
return (Array.isArray(raw) ? raw : [raw])
.map((value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
})
.filter((value): value is number => value !== null)
.map((value) => (integer ? Math.trunc(value) : value));
}
export async function handleMatrixAction(
params: Record<string, unknown>,
cfg: CoreConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId") ?? undefined;
const isActionEnabled = createActionGate(cfg.channels?.["matrix-js"]?.actions);
const clientOpts = accountId ? { accountId } : {};
if (reactionActions.has(action)) {
if (!isActionEnabled("reactions")) {
throw new Error("Matrix reactions are disabled.");
}
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
if (action === "react") {
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Matrix reaction.",
});
if (remove || isEmpty) {
const result = await removeMatrixReactions(roomId, messageId, {
accountId,
emoji: remove ? emoji : undefined,
});
return jsonResult({ ok: true, removed: result.removed });
}
await reactMatrixMessage(roomId, messageId, emoji, { accountId });
return jsonResult({ ok: true, added: emoji });
}
const limit = readNumberParam(params, "limit", { integer: true });
const reactions = await listMatrixReactions(roomId, messageId, {
accountId,
limit: limit ?? undefined,
});
return jsonResult({ ok: true, reactions });
}
if (pollActions.has(action)) {
const roomId = readRoomId(params);
const pollId = readStringParam(params, "pollId", { required: true });
const optionId = readStringParam(params, "pollOptionId");
const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true });
const optionIds = [
...(readStringArrayParam(params, "pollOptionIds") ?? []),
...(optionId ? [optionId] : []),
];
const optionIndexes = [
...readNumericArrayParam(params, "pollOptionIndexes", { integer: true }),
...(optionIndex !== undefined ? [optionIndex] : []),
];
const result = await voteMatrixPoll(roomId, pollId, {
accountId,
optionIds,
optionIndexes,
});
return jsonResult({ ok: true, result });
}
if (messageActions.has(action)) {
if (!isActionEnabled("messages")) {
throw new Error("Matrix messages are disabled.");
}
switch (action) {
case "sendMessage": {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyToId =
readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const result = await sendMatrixMessage(to, content, {
mediaUrl: mediaUrl ?? undefined,
replyToId: replyToId ?? undefined,
threadId: threadId ?? undefined,
...clientOpts,
});
return jsonResult({ ok: true, result });
}
case "editMessage": {
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "content", { required: true });
const result = await editMatrixMessage(roomId, messageId, content, clientOpts);
return jsonResult({ ok: true, result });
}
case "deleteMessage": {
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const reason = readStringParam(params, "reason");
await deleteMatrixMessage(roomId, messageId, {
reason: reason ?? undefined,
...clientOpts,
});
return jsonResult({ ok: true, deleted: true });
}
case "readMessages": {
const roomId = readRoomId(params);
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const result = await readMatrixMessages(roomId, {
limit: limit ?? undefined,
before: before ?? undefined,
after: after ?? undefined,
...clientOpts,
});
return jsonResult({ ok: true, ...result });
}
default:
break;
}
}
if (pinActions.has(action)) {
if (!isActionEnabled("pins")) {
throw new Error("Matrix pins are disabled.");
}
const roomId = readRoomId(params);
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await pinMatrixMessage(roomId, messageId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await unpinMatrixMessage(roomId, messageId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
const result = await listMatrixPins(roomId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
}
if (action === "memberInfo") {
if (!isActionEnabled("memberInfo")) {
throw new Error("Matrix member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
const result = await getMatrixMemberInfo(userId, {
roomId: roomId ?? undefined,
...clientOpts,
});
return jsonResult({ ok: true, member: result });
}
if (action === "channelInfo") {
if (!isActionEnabled("channelInfo")) {
throw new Error("Matrix room info is disabled.");
}
const roomId = readRoomId(params);
const result = await getMatrixRoomInfo(roomId, clientOpts);
return jsonResult({ ok: true, room: result });
}
if (verificationActions.has(action)) {
if (!isActionEnabled("verification")) {
throw new Error("Matrix verification actions are disabled.");
}
const requestId =
readStringParam(params, "requestId") ??
readStringParam(params, "verificationId") ??
readStringParam(params, "id");
if (action === "encryptionStatus") {
const includeRecoveryKey = params.includeRecoveryKey === true;
const status = await getMatrixEncryptionStatus({ includeRecoveryKey, accountId });
return jsonResult({ ok: true, status });
}
if (action === "verificationStatus") {
const includeRecoveryKey = params.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ includeRecoveryKey, accountId });
return jsonResult({ ok: true, status });
}
if (action === "verificationBootstrap") {
const recoveryKey =
readStringParam(params, "recoveryKey", { trim: false }) ??
readStringParam(params, "key", { trim: false });
const result = await bootstrapMatrixVerification({
recoveryKey: recoveryKey ?? undefined,
forceResetCrossSigning: params.forceResetCrossSigning === true,
accountId,
});
return jsonResult({ ok: result.success, result });
}
if (action === "verificationRecoveryKey") {
const recoveryKey =
readStringParam(params, "recoveryKey", { trim: false }) ??
readStringParam(params, "key", { trim: false });
const result = await verifyMatrixRecoveryKey(
readStringParam({ recoveryKey }, "recoveryKey", { required: true, trim: false }),
{ accountId },
);
return jsonResult({ ok: result.success, result });
}
if (action === "verificationBackupStatus") {
const status = await getMatrixRoomKeyBackupStatus({ accountId });
return jsonResult({ ok: true, status });
}
if (action === "verificationBackupRestore") {
const recoveryKey =
readStringParam(params, "recoveryKey", { trim: false }) ??
readStringParam(params, "key", { trim: false });
const result = await restoreMatrixRoomKeyBackup({
recoveryKey: recoveryKey ?? undefined,
accountId,
});
return jsonResult({ ok: result.success, result });
}
if (action === "verificationList") {
const verifications = await listMatrixVerifications({ accountId });
return jsonResult({ ok: true, verifications });
}
if (action === "verificationRequest") {
const userId = readStringParam(params, "userId");
const deviceId = readStringParam(params, "deviceId");
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined;
const verification = await requestMatrixVerification({
ownUser,
userId: userId ?? undefined,
deviceId: deviceId ?? undefined,
roomId: roomId ?? undefined,
accountId,
});
return jsonResult({ ok: true, verification });
}
if (action === "verificationAccept") {
const verification = await acceptMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationCancel") {
const reason = readStringParam(params, "reason");
const code = readStringParam(params, "code");
const verification = await cancelMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ reason: reason ?? undefined, code: code ?? undefined, accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationStart") {
const methodRaw = readStringParam(params, "method");
const method = methodRaw?.trim().toLowerCase();
if (method && method !== "sas") {
throw new Error(
"Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.",
);
}
const verification = await startMatrixVerification(
readStringParam({ requestId }, "requestId", { required: true }),
{ method: "sas", accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationGenerateQr") {
const qr = await generateMatrixVerificationQr(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, ...qr });
}
if (action === "verificationScanQr") {
const qrDataBase64 =
readStringParam(params, "qrDataBase64") ??
readStringParam(params, "qrData") ??
readStringParam(params, "qr");
const verification = await scanMatrixVerificationQr(
readStringParam({ requestId }, "requestId", { required: true }),
readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationSas") {
const sas = await getMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, sas });
}
if (action === "verificationConfirm") {
const verification = await confirmMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationMismatch") {
const verification = await mismatchMatrixVerificationSas(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
if (action === "verificationConfirmQr") {
const verification = await confirmMatrixVerificationReciprocateQr(
readStringParam({ requestId }, "requestId", { required: true }),
{ accountId },
);
return jsonResult({ ok: true, verification });
}
}
throw new Error(`Unsupported Matrix action: ${action}`);
}

View File

@@ -1,141 +0,0 @@
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/matrix-js";
export type { DmPolicy, GroupPolicy };
export type ReplyToMode = "off" | "first" | "all";
export type MatrixDmConfig = {
/** If false, ignore all incoming Matrix DMs. Default: true. */
enabled?: boolean;
/** Direct message access policy (default: pairing). */
policy?: DmPolicy;
/** Allowlist for DM senders (matrix user IDs or "*"). */
allowFrom?: Array<string | number>;
};
export type MatrixRoomConfig = {
/** If false, disable the bot in this room (alias for allow: false). */
enabled?: boolean;
/** Legacy room allow toggle; prefer enabled. */
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Optional tool policy overrides for this room. */
tools?: { allow?: string[]; deny?: string[] };
/** If true, reply without mention requirements. */
autoReply?: boolean;
/** Optional allowlist for room senders (matrix user IDs). */
users?: Array<string | number>;
/** Optional skill filter for this room. */
skills?: string[];
/** Optional system prompt snippet for this room. */
systemPrompt?: string;
};
export type MatrixActionConfig = {
reactions?: boolean;
messages?: boolean;
pins?: boolean;
memberInfo?: boolean;
channelInfo?: boolean;
verification?: boolean;
};
export type MatrixThreadBindingsConfig = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSubagentSessions?: boolean;
spawnAcpSessions?: boolean;
};
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
export type MatrixConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start Matrix. Default: true. */
enabled?: boolean;
/** Multi-account configuration keyed by account ID. */
accounts?: Record<string, MatrixAccountConfig>;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
/** Matrix user id (@user:server). */
userId?: string;
/** Matrix access token. */
accessToken?: string;
/** Matrix password (used only to fetch access token). */
password?: string;
/** Optional Matrix device id (recommended when using access tokens + E2EE). */
deviceId?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Optional desired Matrix avatar source (mxc:// or http(s) URL). */
avatarUrl?: string;
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
/** If true, enforce allowlists for groups + DMs regardless of policy. */
allowlistOnly?: boolean;
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Allowlist for group senders (matrix user IDs). */
groupAllowFrom?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/** How to handle thread replies (off|inbound|always). */
threadReplies?: "off" | "inbound" | "always";
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Ack reaction emoji override for this channel/account. */
ackReaction?: string;
/** Ack reaction scope override for this channel/account. */
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
/** Inbound reaction notifications for bot-authored Matrix messages. */
reactionNotifications?: "off" | "own";
/** Thread/session binding behavior for Matrix room threads. */
threadBindings?: MatrixThreadBindingsConfig;
/** Whether Matrix-js should auto-request self verification on startup when unverified. */
startupVerification?: "off" | "if-unverified";
/** Cooldown window for automatic startup verification requests. Default: 24 hours. */
startupVerificationCooldownHours?: number;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
/** Auto-join invites (always|allowlist|off). Default: always. */
autoJoin?: "always" | "allowlist" | "off";
/** Allowlist for auto-join invites (room IDs, aliases). */
autoJoinAllowlist?: Array<string | number>;
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */
groups?: Record<string, MatrixRoomConfig>;
/** Room config allowlist keyed by room ID or alias. Legacy; use groups. */
rooms?: Record<string, MatrixRoomConfig>;
/** Per-action tool gating (default: true for all). */
actions?: MatrixActionConfig;
};
export type CoreConfig = {
channels?: {
"matrix-js"?: MatrixConfig;
defaults?: {
groupPolicy?: "open" | "allowlist" | "disabled";
};
};
commands?: {
useAccessGroups?: boolean;
};
session?: {
store?: string;
};
messages?: {
ackReaction?: string;
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
};
[key: string]: unknown;
};

View File

@@ -1,6 +1,12 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/matrix";
import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk/matrix";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix";
import { matrixPlugin } from "./src/channel.js";
import { registerMatrixCli } from "./src/cli.js";
import {
bootstrapMatrixVerification,
getMatrixVerificationStatus,
verifyMatrixRecoveryKey,
} from "./src/matrix/actions/verification.js";
import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js";
import { setMatrixRuntime } from "./src/runtime.js";
@@ -11,11 +17,83 @@ const plugin = {
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMatrixRuntime(api.runtime);
void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => {
void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
});
api.registerChannel({ plugin: matrixPlugin });
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: err instanceof Error ? err.message : String(err) });
};
api.registerGatewayMethod(
"matrix.verify.recoveryKey",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const key = typeof params?.key === "string" ? params.key : "";
if (!key.trim()) {
respond(false, { error: "key required" });
return;
}
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const result = await verifyMatrixRecoveryKey(key, { accountId });
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix.verify.bootstrap",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const recoveryKey =
typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined;
const forceResetCrossSigning = params?.forceResetCrossSigning === true;
const result = await bootstrapMatrixVerification({
accountId,
recoveryKey,
forceResetCrossSigning,
});
respond(result.success, result);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerGatewayMethod(
"matrix.verify.status",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const accountId =
typeof params?.accountId === "string"
? params.accountId.trim() || undefined
: undefined;
const includeRecoveryKey = params?.includeRecoveryKey === true;
const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey });
respond(true, status);
} catch (err) {
sendError(respond, err);
}
},
);
api.registerCli(
({ program }) => {
registerMatrixCli({ program });
},
{ commands: ["matrix"] },
);
},
};

View File

@@ -1,16 +1,19 @@
{
"name": "@openclaw/matrix",
"version": "2026.3.9",
"version": "2026.3.8",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-agent-core": "0.57.1",
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"markdown-it": "14.1.1",
"music-metadata": "^11.12.1",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
@@ -33,7 +36,7 @@
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@matrix-org/matrix-sdk-crypto-nodejs",
"@vector-im/matrix-bot-sdk",
"matrix-js-sdk",
"music-metadata"
]
}

View File

@@ -29,7 +29,7 @@ async function main() {
invite: [targetUserId],
preset: "trusted_private_chat",
name: `OpenClaw DM Test ${stamp}`,
topic: "matrix-js basic DM messaging test",
topic: "matrix basic DM messaging test",
},
)) as { room_id?: string };
@@ -48,12 +48,12 @@ async function main() {
const dmByUserTarget = await sendMatrixMessage(
targetUserId,
`Matrix-js basic DM test (user target) ${stamp}`,
`Matrix basic DM test (user target) ${stamp}`,
{ client },
);
const dmByRoomTarget = await sendMatrixMessage(
dmRoomId,
`Matrix-js basic DM test (room target) ${stamp}`,
`Matrix basic DM test (room target) ${stamp}`,
{ client },
);
@@ -61,7 +61,7 @@ async function main() {
invite: [targetUserId],
preset: "private_chat",
name: `OpenClaw Room Test ${stamp}`,
topic: "matrix-js basic room messaging test",
topic: "matrix basic room messaging test",
})) as { room_id?: string };
const roomId = roomCreate.room_id?.trim() ?? "";
@@ -69,7 +69,7 @@ async function main() {
throw new Error("Failed to create room chat room");
}
const roomSend = await sendMatrixMessage(roomId, `Matrix-js basic room test ${stamp}`, {
const roomSend = await sendMatrixMessage(roomId, `Matrix basic room test ${stamp}`, {
client,
});

View File

@@ -82,7 +82,7 @@ export function resolveLiveHarnessConfig(): LiveHarnessConfig {
export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): {
channels: {
"matrix-js": {
matrix: {
homeserver: string;
userId: string;
password: string;
@@ -92,7 +92,7 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): {
} {
const pluginCfg = {
channels: {
"matrix-js": {
matrix: {
homeserver: cfg.homeserver,
userId: cfg.userId,
password: cfg.password,
@@ -106,7 +106,7 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): {
loadConfig: () => pluginCfg,
},
state: {
resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-js-live-harness-state"),
resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-live-harness-state"),
},
channel: {
text: {
@@ -133,9 +133,9 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): {
},
isVoiceCompatibleAudio: () => false,
loadWebMedia: async () => ({
buffer: Buffer.from("matrix-js harness media payload\n", "utf8"),
buffer: Buffer.from("matrix harness media payload\n", "utf8"),
contentType: "text/plain",
fileName: "matrix-js-harness.txt",
fileName: "matrix-harness.txt",
kind: "document" as const,
}),
},

View File

@@ -15,7 +15,7 @@ type MatrixCryptoProbe = {
async function main() {
const base = resolveLiveHarnessConfig();
const cfg = installLiveHarnessRuntime(base);
(cfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
(cfg.channels["matrix"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: cfg as never });
const client = await createMatrixClient({

View File

@@ -7,7 +7,7 @@ async function main() {
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
(pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true;
const result = await bootstrapMatrixVerification({
recoveryKey: recoveryKeyArg?.trim() || undefined,

View File

@@ -7,13 +7,13 @@ async function main() {
if (!roomId) {
throw new Error(
"Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-room-state.ts <roomId> [eventId]",
"Usage: node --import tsx extensions/matrix/scripts/live-e2ee-room-state.ts <roomId> [eventId]",
);
}
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
(pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({

View File

@@ -15,13 +15,13 @@ async function main() {
if (!roomId) {
throw new Error(
"Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-send-room.ts <roomId> [settleMs] [--full-bootstrap]",
"Usage: node --import tsx extensions/matrix/scripts/live-e2ee-send-room.ts <roomId> [settleMs] [--full-bootstrap]",
);
}
const base = resolveLiveHarnessConfig();
const pluginCfg = installLiveHarnessRuntime(base);
(pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true;
(pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true;
const auth = await resolveMatrixAuth({ cfg: pluginCfg as never });
const client = await createMatrixClient({
@@ -64,7 +64,7 @@ async function main() {
const sent = await sendMatrixMessage(
roomId,
`Matrix-js E2EE existing-room test ${stamp} (settleMs=${settleMs})`,
`Matrix E2EE existing-room test ${stamp} (settleMs=${settleMs})`,
{ client },
);

Some files were not shown because too many files have changed in this diff Show More