mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 22:10:51 +00:00
Matrix: rebuild plugin migration branch
This commit is contained in:
@@ -1,83 +1,70 @@
|
||||
---
|
||||
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 setup 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"
|
||||
}'
|
||||
```
|
||||
Interactive setup paths:
|
||||
|
||||
- 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.
|
||||
```bash
|
||||
openclaw channels add
|
||||
openclaw configure --section channels
|
||||
```
|
||||
|
||||
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 setup).
|
||||
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.
|
||||
What the Matrix wizard actually asks for:
|
||||
|
||||
Minimal config (access token, user ID auto-fetched):
|
||||
- homeserver URL
|
||||
- auth method: access token or password
|
||||
- user ID only when you choose password auth
|
||||
- optional device name
|
||||
- whether to enable E2EE
|
||||
- whether to configure Matrix room access now
|
||||
|
||||
Wizard behavior that matters:
|
||||
|
||||
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
|
||||
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
|
||||
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
|
||||
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
|
||||
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
|
||||
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||
|
||||
Minimal token-based setup:
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -85,14 +72,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 +87,92 @@ E2EE config (end to end encryption enabled):
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_***",
|
||||
userId: "@bot:example.org",
|
||||
password: "replace-me", // pragma: allowlist secret
|
||||
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`
|
||||
|
||||
For normalized account ID `ops-bot`, use:
|
||||
|
||||
- `MATRIX_OPS_BOT_HOMESERVER`
|
||||
- `MATRIX_OPS_BOT_ACCESS_TOKEN`
|
||||
|
||||
The interactive wizard only offers the env-var shortcut when those auth env vars are already present and the selected account does not already have Matrix auth saved in config.
|
||||
|
||||
## 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,60 +180,371 @@ 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.
|
||||
Include the stored recovery key in machine-readable output:
|
||||
|
||||
**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 status --include-recovery-key --json
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
Bootstrap cross-signing and verification state:
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.).
|
||||
Verbose bootstrap diagnostics:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap --verbose
|
||||
```
|
||||
|
||||
Force a fresh cross-signing identity reset before bootstrapping:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap --force-reset-cross-signing
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Delete the current server backup and create a fresh backup baseline:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
In multi-account setups, Matrix CLI commands use the implicit Matrix default account unless you pass `--account <id>`.
|
||||
If you configure multiple named accounts, set `channels.matrix.defaultAccount` first or those implicit CLI operations will stop and ask you to choose an account explicitly.
|
||||
Use `--account` whenever you want verification or device operations to target a named account explicitly:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify status --account assistant
|
||||
openclaw matrix verify backup restore --account assistant
|
||||
openclaw matrix devices list --account assistant
|
||||
```
|
||||
|
||||
When encryption is disabled or unavailable for a named account, Matrix warnings and verification errors point at that account's config key, for example `channels.matrix.accounts.assistant.encryption`.
|
||||
|
||||
### What "verified" means
|
||||
|
||||
OpenClaw treats this Matrix device as verified only when it is verified by your own cross-signing identity.
|
||||
In practice, `openclaw matrix verify status --verbose` exposes three trust signals:
|
||||
|
||||
- `Locally trusted`: this device is trusted by the current client only
|
||||
- `Cross-signing verified`: the SDK reports the device as verified through cross-signing
|
||||
- `Signed by owner`: the device is signed by your own self-signing key
|
||||
|
||||
`Verified by owner` becomes `yes` only when cross-signing verification or owner-signing is present.
|
||||
Local trust by itself is not enough for OpenClaw to treat the device as fully verified.
|
||||
|
||||
### What bootstrap does
|
||||
|
||||
`openclaw matrix verify bootstrap` is the repair and setup command for encrypted Matrix accounts.
|
||||
It does all of the following in order:
|
||||
|
||||
- bootstraps secret storage, reusing an existing recovery key when possible
|
||||
- bootstraps cross-signing and uploads missing public cross-signing keys
|
||||
- attempts to mark and cross-sign the current device
|
||||
- creates a new server-side room-key backup if one does not already exist
|
||||
|
||||
If the homeserver requires interactive auth to upload cross-signing keys, OpenClaw tries the upload without auth first, then with `m.login.dummy`, then with `m.login.password` when `channels.matrix.password` is configured.
|
||||
|
||||
Use `--force-reset-cross-signing` only when you intentionally want to discard the current cross-signing identity and create a new one.
|
||||
|
||||
If you intentionally want to discard the current room-key backup and start a new backup baseline for future messages, use `openclaw matrix verify backup reset --yes`.
|
||||
Do this only when you accept that unrecoverable old encrypted history will stay unavailable.
|
||||
|
||||
### Fresh backup baseline
|
||||
|
||||
If you want to keep future encrypted messages working and accept losing unrecoverable old history, run these commands in order:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
openclaw matrix verify backup status --verbose
|
||||
openclaw matrix verify status
|
||||
```
|
||||
|
||||
Add `--account <id>` to each command when you want to target a named Matrix account explicitly.
|
||||
|
||||
### Startup behavior
|
||||
|
||||
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.
|
||||
|
||||
Startup also performs a conservative crypto bootstrap pass automatically.
|
||||
That pass tries to reuse the current secret storage and cross-signing identity first, and avoids resetting cross-signing unless you run an explicit bootstrap repair flow.
|
||||
|
||||
If startup finds broken bootstrap state and `channels.matrix.password` is configured, OpenClaw can attempt a stricter repair path.
|
||||
If the current device is already owner-signed, OpenClaw preserves that identity instead of resetting it automatically.
|
||||
|
||||
Upgrading from the previous public Matrix plugin:
|
||||
|
||||
- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible.
|
||||
- Before any actionable Matrix migration changes run, OpenClaw creates or reuses a recovery snapshot under `~/Backups/openclaw-migrations/`.
|
||||
- If you use multiple Matrix accounts, set `channels.matrix.defaultAccount` before upgrading from the old flat-store layout so OpenClaw knows which account should receive that shared legacy state.
|
||||
- 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.
|
||||
- If the Matrix access token changed after migration was prepared, startup now scans sibling token-hash storage roots for pending legacy restore state before giving up on the automatic backup restore.
|
||||
- If the Matrix access token changes later for the same account, homeserver, and user, OpenClaw now prefers reusing the most complete existing token-hash storage root instead of starting from an empty Matrix state directory.
|
||||
- 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 organized under per-account, per-user token-hash roots 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.
|
||||
When the token changes but the account identity stays the same, OpenClaw reuses the best existing
|
||||
root for that account/homeserver/user tuple so prior sync state, crypto state, thread bindings,
|
||||
and startup verification state remain visible.
|
||||
|
||||
### Node crypto store model
|
||||
|
||||
Matrix E2EE in this plugin uses the official `matrix-js-sdk` Rust crypto path in Node.
|
||||
That path expects IndexedDB-backed persistence when you want crypto state to survive restarts.
|
||||
|
||||
OpenClaw currently provides that in Node by:
|
||||
|
||||
- using `fake-indexeddb` as the IndexedDB API shim expected by the SDK
|
||||
- restoring the Rust crypto IndexedDB contents from `crypto-idb-snapshot.json` before `initRustCrypto`
|
||||
- persisting the updated IndexedDB contents back to `crypto-idb-snapshot.json` after init and during runtime
|
||||
|
||||
This is compatibility/storage plumbing, not a custom crypto implementation.
|
||||
The snapshot file is sensitive runtime state and is stored with restrictive file permissions.
|
||||
Under OpenClaw's security model, the gateway host and local OpenClaw state directory are already inside the trusted operator boundary, so this is primarily an operational durability concern rather than a separate remote trust boundary.
|
||||
|
||||
Planned improvement:
|
||||
|
||||
- add SecretRef support for persistent Matrix key material so recovery keys and related store-encryption secrets can be sourced from OpenClaw secrets providers instead of only local files
|
||||
|
||||
## 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
|
||||
|
||||
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
|
||||
When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side.
|
||||
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
|
||||
|
||||
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.
|
||||
|
||||
Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`.
|
||||
|
||||
### Device hygiene
|
||||
|
||||
Old OpenClaw-managed Matrix devices can accumulate on the account and make encrypted-room trust harder to reason about.
|
||||
List them with:
|
||||
|
||||
```bash
|
||||
openclaw matrix devices list
|
||||
```
|
||||
|
||||
Remove stale OpenClaw-managed devices with:
|
||||
|
||||
```bash
|
||||
openclaw matrix devices prune-stale
|
||||
```
|
||||
|
||||
### Direct Room Repair
|
||||
|
||||
If direct-message state gets out of sync, OpenClaw can end up with stale `m.direct` mappings that point at old solo rooms instead of the live DM. Inspect the current mapping for a peer with:
|
||||
|
||||
```bash
|
||||
openclaw matrix direct inspect --user-id @alice:example.org
|
||||
```
|
||||
|
||||
Repair it with:
|
||||
|
||||
```bash
|
||||
openclaw matrix direct repair --user-id @alice:example.org
|
||||
```
|
||||
|
||||
Repair keeps the Matrix-specific logic inside the plugin:
|
||||
|
||||
- it prefers a strict 1:1 DM that is already mapped in `m.direct`
|
||||
- otherwise it falls back to any currently joined strict 1:1 DM with that user
|
||||
- if no healthy DM exists, it creates a fresh direct room and rewrites `m.direct` to point at it
|
||||
|
||||
The repair flow does not delete old rooms automatically. It only picks the healthy DM and updates the mapping so new Matrix sends, verification notices, and other direct-message flows target the right room again.
|
||||
|
||||
## 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.
|
||||
|
||||
Pairing example for Matrix DMs:
|
||||
|
||||
```bash
|
||||
openclaw pairing list matrix
|
||||
openclaw pairing approve matrix <CODE>
|
||||
```
|
||||
|
||||
If an unapproved Matrix user keeps messaging you before approval, OpenClaw reuses the same pending pairing code and may send a reminder reply again after a short cooldown instead of minting a new code.
|
||||
|
||||
See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layout.
|
||||
|
||||
## Multi-account example
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
defaultAccount: "assistant",
|
||||
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 +552,60 @@ inherits from the top-level `channels.matrix` settings and can override any opti
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
|
||||
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
|
||||
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
|
||||
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.
|
||||
|
||||
- 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).
|
||||
## Target resolution
|
||||
|
||||
## Routing model
|
||||
Matrix accepts these target forms anywhere OpenClaw asks you for a room or user target:
|
||||
|
||||
- Replies always go back to Matrix.
|
||||
- DMs share the agent's main session; rooms map to group sessions.
|
||||
- Users: `@user:server`, `user:@user:server`, or `matrix:user:@user:server`
|
||||
- Rooms: `!room:server`, `room:!room:server`, or `matrix:room:!room:server`
|
||||
- Aliases: `#alias:server`, `channel:#alias:server`, or `matrix:channel:#alias:server`
|
||||
|
||||
## Access control (DMs)
|
||||
Live directory lookup uses the logged-in Matrix account:
|
||||
|
||||
- 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.
|
||||
- User lookups query the Matrix user directory on that homeserver.
|
||||
- Room lookups accept explicit room IDs and aliases directly, then fall back to searching joined room names for that account.
|
||||
- Joined-room name lookup is best-effort. If a room name cannot be resolved to an ID or alias, it is ignored by runtime allowlist resolution.
|
||||
|
||||
## Rooms (groups)
|
||||
## Configuration reference
|
||||
|
||||
- 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.
|
||||
- `name`: optional label for the account.
|
||||
- `defaultAccount`: preferred account ID when multiple Matrix accounts are configured.
|
||||
- `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.
|
||||
- `avatarUrl`: stored self-avatar URL for profile sync and `set-profile` updates.
|
||||
- `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.
|
||||
- `groupAllowFrom` entries should be full Matrix user IDs. Unresolved names are ignored at runtime.
|
||||
- `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`). Default: `off`.
|
||||
- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. Alias entries are resolved to room IDs during invite handling; OpenClaw does not trust alias state claimed by the invited room.
|
||||
- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`).
|
||||
- `dm.allowFrom` entries should be full Matrix user IDs unless you already resolved them through live directory lookup.
|
||||
- `accounts`: named per-account overrides. Top-level `channels.matrix` values act as defaults for these entries.
|
||||
- `groups`: per-room policy map. Prefer room IDs or aliases; unresolved room names are ignored at runtime. Session/group identity uses the stable room ID after resolution, while human-readable labels still come from room names.
|
||||
- `rooms`: legacy alias for `groups`.
|
||||
- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `profile`, `memberInfo`, `channelInfo`, `verification`).
|
||||
|
||||
344
docs/install/migrating-matrix.md
Normal file
344
docs/install/migrating-matrix.md
Normal file
@@ -0,0 +1,344 @@
|
||||
---
|
||||
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.
|
||||
Before any actionable Matrix migration step mutates on-disk state, OpenClaw creates or reuses a focused recovery snapshot.
|
||||
|
||||
When you use `openclaw update`, the exact trigger depends on how OpenClaw is installed:
|
||||
|
||||
- source installs run `openclaw doctor --fix` during the update flow, then restart the gateway by default
|
||||
- package-manager installs update the package, run a non-interactive doctor pass, then rely on the default gateway restart so startup can finish Matrix migration
|
||||
- if you use `openclaw update --no-restart`, startup-backed Matrix migration is deferred until you later run `openclaw doctor --fix` and restart the gateway
|
||||
|
||||
Automatic migration covers:
|
||||
|
||||
- creating or reusing a pre-migration snapshot under `~/Backups/openclaw-migrations/`
|
||||
- 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
|
||||
- reusing the most complete existing token-hash storage root for the same Matrix account, homeserver, and user when the access token changes later
|
||||
- scanning sibling token-hash storage roots for pending encrypted-state restore metadata when the Matrix access token changed but the account/device identity stayed the same
|
||||
- restoring backed-up room keys into the new crypto store on the next Matrix startup
|
||||
|
||||
Snapshot details:
|
||||
|
||||
- OpenClaw writes a marker file at `~/.openclaw/matrix/migration-snapshot.json` after a successful snapshot so later startup and repair passes can reuse the same archive.
|
||||
- These automatic Matrix migration snapshots back up config + state only (`includeWorkspace: false`).
|
||||
- If Matrix only has warning-only migration state, for example because `userId` or `accessToken` is still missing, OpenClaw does not create the snapshot yet because no Matrix mutation is actionable.
|
||||
- If the snapshot step fails, OpenClaw skips Matrix migration for that run instead of mutating state without a recovery point.
|
||||
|
||||
About multi-account upgrades:
|
||||
|
||||
- the oldest flat Matrix store (`~/.openclaw/matrix/bot-storage.json` and `~/.openclaw/matrix/crypto/`) came from a single-store layout, so OpenClaw can only migrate it into one resolved Matrix account target
|
||||
- already account-scoped legacy Matrix stores are detected and prepared per configured Matrix account
|
||||
|
||||
## 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
|
||||
- automatic migration of one shared flat Matrix store when multiple Matrix accounts are configured but `channels.matrix.defaultAccount` is not set
|
||||
- custom plugin path installs that are pinned to a repo path instead of the standard Matrix package
|
||||
- a missing recovery key when the old store had backed-up keys but did not keep the decryption key locally
|
||||
|
||||
Current warning scope:
|
||||
|
||||
- custom Matrix plugin path installs are surfaced by both gateway startup and `openclaw doctor`
|
||||
|
||||
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.
|
||||
Prefer plain `openclaw update` without `--no-restart` so startup can finish the Matrix migration immediately.
|
||||
2. Run:
|
||||
|
||||
```bash
|
||||
openclaw doctor --fix
|
||||
```
|
||||
|
||||
If Matrix has actionable migration work, doctor will create or reuse the pre-migration snapshot first and print the archive path.
|
||||
|
||||
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 you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
8. 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` creates or reuses the pre-migration snapshot if encrypted migration is actionable.
|
||||
2. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store through the active Matrix plugin install.
|
||||
3. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending.
|
||||
4. 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.
|
||||
|
||||
`Matrix migration snapshot created before applying Matrix upgrades.`
|
||||
|
||||
- Meaning: OpenClaw created a recovery archive before mutating Matrix state.
|
||||
- What to do: keep the printed archive path until you confirm migration succeeded.
|
||||
|
||||
`Matrix migration snapshot reused before applying Matrix upgrades.`
|
||||
|
||||
- Meaning: OpenClaw found an existing Matrix migration snapshot marker and reused that archive instead of creating a duplicate backup.
|
||||
- What to do: keep the printed archive path until you confirm migration succeeded.
|
||||
|
||||
`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.
|
||||
|
||||
`Legacy Matrix state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
|
||||
|
||||
- Meaning: OpenClaw found one shared flat Matrix store, but it refuses to guess which named Matrix account should receive it.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`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.
|
||||
|
||||
`Legacy Matrix encrypted state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
|
||||
|
||||
- Meaning: OpenClaw found one shared flat legacy crypto store, but it refuses to guess which named Matrix account should receive it.
|
||||
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.`
|
||||
|
||||
- Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data.
|
||||
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Legacy Matrix encrypted state was detected, but the Matrix plugin helper is unavailable. Install or repair @openclaw/matrix so OpenClaw can inspect the old rust crypto store before upgrading.`
|
||||
|
||||
- Meaning: OpenClaw found old encrypted Matrix state, but it could not load the helper entrypoint from the Matrix plugin that normally inspects that store.
|
||||
- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./extensions/matrix` for a repo checkout), then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Matrix plugin helper path is unsafe: ... Reinstall @openclaw/matrix and try again.`
|
||||
|
||||
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
|
||||
|
||||
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
|
||||
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`Failed migrating legacy Matrix client storage: ...`
|
||||
|
||||
- Meaning: the Matrix client-side fallback found old flat storage, but the move failed. OpenClaw now aborts that fallback instead of silently starting with a fresh store.
|
||||
- What to do: inspect filesystem permissions or conflicts, keep the old state intact, and retry after fixing the error.
|
||||
|
||||
`Matrix is installed from a custom path: ...`
|
||||
|
||||
- Meaning: Matrix is pinned to a path install, so mainline updates do not automatically replace it with the repo's standard Matrix package.
|
||||
- What to do: reinstall with `openclaw plugins install @openclaw/matrix` when you want to return to the default Matrix plugin.
|
||||
|
||||
### 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.
|
||||
|
||||
If you accept losing unrecoverable old encrypted history, you can instead reset the current backup baseline with `openclaw matrix verify backup reset --yes`.
|
||||
|
||||
`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.
|
||||
|
||||
## If you want to start fresh for future messages
|
||||
|
||||
If you accept losing unrecoverable old encrypted history and only want a clean backup baseline going forward, run these commands in order:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
openclaw matrix verify backup status --verbose
|
||||
openclaw matrix verify status
|
||||
```
|
||||
|
||||
If the device is still unverified after that, finish verification from your Matrix client by comparing the SAS emoji or decimal codes and confirming that they match.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Matrix](/channels/matrix)
|
||||
- [Doctor](/gateway/doctor)
|
||||
- [Migrating](/install/migrating)
|
||||
- [Plugins](/tools/plugin)
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js";
|
||||
|
||||
3
extensions/matrix/helper-api.ts
Normal file
3
extensions/matrix/helper-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./src/account-selection.js";
|
||||
export * from "./src/env-vars.js";
|
||||
export * from "./src/storage-paths.js";
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { matrixPlugin } from "./src/channel.js";
|
||||
import { registerMatrixCli } from "./src/cli.js";
|
||||
import { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
export { matrixPlugin } from "./src/channel.js";
|
||||
@@ -8,7 +9,42 @@ export { setMatrixRuntime } from "./src/runtime.js";
|
||||
export default defineChannelPluginEntry({
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin",
|
||||
description: "Matrix channel plugin (matrix-js-sdk)",
|
||||
plugin: matrixPlugin,
|
||||
setRuntime: setMatrixRuntime,
|
||||
registerFull(api) {
|
||||
void import("./src/plugin-entry.runtime.js")
|
||||
.then(({ ensureMatrixCryptoRuntime }) =>
|
||||
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}`);
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
|
||||
const { handleVerifyRecoveryKey } = await import("./src/plugin-entry.runtime.js");
|
||||
await handleVerifyRecoveryKey(ctx);
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("matrix.verify.bootstrap", async (ctx) => {
|
||||
const { handleVerificationBootstrap } = await import("./src/plugin-entry.runtime.js");
|
||||
await handleVerificationBootstrap(ctx);
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("matrix.verify.status", async (ctx) => {
|
||||
const { handleVerificationStatus } = await import("./src/plugin-entry.runtime.js");
|
||||
await handleVerificationStatus(ctx);
|
||||
});
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerMatrixCli({ program });
|
||||
},
|
||||
{ commands: ["matrix"] },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.3.14",
|
||||
"version": "2026.3.11",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "0.60.0",
|
||||
"@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.3",
|
||||
"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"
|
||||
@@ -31,8 +34,12 @@
|
||||
"localPath": "extensions/matrix",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
"matrix-js-sdk",
|
||||
"music-metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export * from "./src/auth-precedence.js";
|
||||
export * from "./helper-api.js";
|
||||
|
||||
106
extensions/matrix/src/account-selection.ts
Normal file
106
extensions/matrix/src/account-selection.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { listMatrixEnvAccountIds } from "./env-vars.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
|
||||
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
|
||||
}
|
||||
|
||||
export function findMatrixAccountEntry(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): Record<string, unknown> | null {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (!accounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
for (const [rawAccountId, value] of Object.entries(accounts)) {
|
||||
if (normalizeAccountId(rawAccountId) === normalizedAccountId && isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredMatrixAccountIds(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string[] {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
const ids = new Set<string>(listMatrixEnvAccountIds(env));
|
||||
|
||||
const accounts = channel && isRecord(channel.accounts) ? channel.accounts : null;
|
||||
if (accounts) {
|
||||
for (const [accountId, value] of Object.entries(accounts)) {
|
||||
if (isRecord(value)) {
|
||||
ids.add(normalizeAccountId(accountId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.size === 0 && channel) {
|
||||
ids.add(DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveMatrixDefaultOrOnlyAccountId(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
|
||||
if (configuredDefault && configuredAccountIds.includes(configuredDefault)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (configuredAccountIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
if (configuredAccountIds.length === 1) {
|
||||
return configuredAccountIds[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function requiresExplicitMatrixDefaultAccount(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
const channel = resolveMatrixChannelConfig(cfg);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
|
||||
if (configuredAccountIds.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const configuredDefault = normalizeOptionalAccountId(
|
||||
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
|
||||
);
|
||||
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
|
||||
}
|
||||
182
extensions/matrix/src/actions.account-propagation.test.ts
Normal file
182
extensions/matrix/src/actions.account-propagation.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleMatrixAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./tool-actions.js", () => ({
|
||||
handleMatrixAction: mocks.handleMatrixAction,
|
||||
}));
|
||||
|
||||
const { matrixMessageActions } = await import("./actions.js");
|
||||
|
||||
function createContext(
|
||||
overrides: Partial<ChannelMessageActionContext>,
|
||||
): ChannelMessageActionContext {
|
||||
return {
|
||||
channel: "matrix",
|
||||
action: "send",
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
params: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrixMessageActions account propagation", () => {
|
||||
beforeEach(() => {
|
||||
mocks.handleMatrixAction.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
output: "",
|
||||
details: { ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards accountId for send actions", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
message: "hello",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sendMessage",
|
||||
accountId: "ops",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards accountId for permissions actions", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "permissions",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
operation: "verification-list",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "verificationList",
|
||||
accountId: "ops",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards accountId for self-profile updates", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "set-profile",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "setProfile",
|
||||
accountId: "ops",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards local avatar paths for self-profile updates", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "set-profile",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
path: "/tmp/avatar.jpg",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "setProfile",
|
||||
accountId: "ops",
|
||||
avatarPath: "/tmp/avatar.jpg",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots for media sends", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
message: "hello",
|
||||
media: "file:///tmp/photo.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sendMessage",
|
||||
accountId: "ops",
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("allows media-only sends without requiring a message body", async () => {
|
||||
await matrixMessageActions.handleAction?.(
|
||||
createContext({
|
||||
action: "send",
|
||||
accountId: "ops",
|
||||
params: {
|
||||
to: "room:!room:example",
|
||||
media: "file:///tmp/photo.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.handleMatrixAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "sendMessage",
|
||||
accountId: "ops",
|
||||
content: undefined,
|
||||
mediaUrl: "file:///tmp/photo.png",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{ mediaLocalRoots: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
151
extensions/matrix/src/actions.test.ts
Normal file
151
extensions/matrix/src/actions.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: async () => null,
|
||||
resizeToJpeg: async () => Buffer.from(""),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: () => "/tmp/openclaw-matrix-test",
|
||||
},
|
||||
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;
|
||||
|
||||
function createConfiguredMatrixConfig(): CoreConfig {
|
||||
return {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
describe("matrixMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("exposes poll create but only handles poll votes inside the plugin", () => {
|
||||
const describeMessageTool = matrixMessageActions.describeMessageTool;
|
||||
const supportsAction = matrixMessageActions.supportsAction;
|
||||
|
||||
expect(describeMessageTool).toBeTypeOf("function");
|
||||
expect(supportsAction).toBeTypeOf("function");
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toContain("poll");
|
||||
expect(actions).toContain("poll-vote");
|
||||
expect(supportsAction!({ action: "poll" } as never)).toBe(false);
|
||||
expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes and describes self-profile updates", () => {
|
||||
const describeMessageTool = matrixMessageActions.describeMessageTool;
|
||||
const supportsAction = matrixMessageActions.supportsAction;
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
const actions = discovery.actions;
|
||||
const properties =
|
||||
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
|
||||
|
||||
expect(actions).toContain("set-profile");
|
||||
expect(supportsAction!({ action: "set-profile" } as never)).toBe(true);
|
||||
expect(properties.displayName).toBeDefined();
|
||||
expect(properties.avatarUrl).toBeDefined();
|
||||
expect(properties.avatarPath).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides gated actions when the default Matrix account disables them", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
@@ -5,43 +6,132 @@ import {
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelMessageToolDiscovery,
|
||||
type ChannelToolSend,
|
||||
} from "../runtime-api.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { handleMatrixAction } from "./tool-actions.js";
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"send",
|
||||
"poll-vote",
|
||||
"react",
|
||||
"reactions",
|
||||
"read",
|
||||
"edit",
|
||||
"delete",
|
||||
"pin",
|
||||
"unpin",
|
||||
"list-pins",
|
||||
"set-profile",
|
||||
"member-info",
|
||||
"channel-info",
|
||||
"permissions",
|
||||
]);
|
||||
|
||||
function createMatrixExposedActions(params: {
|
||||
gate: ReturnType<typeof createActionGate>;
|
||||
encryptionEnabled: boolean;
|
||||
}) {
|
||||
const actions = new Set<ChannelMessageActionName>(["poll", "poll-vote"]);
|
||||
if (params.gate("messages")) {
|
||||
actions.add("send");
|
||||
actions.add("read");
|
||||
actions.add("edit");
|
||||
actions.add("delete");
|
||||
}
|
||||
if (params.gate("reactions")) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
if (params.gate("pins")) {
|
||||
actions.add("pin");
|
||||
actions.add("unpin");
|
||||
actions.add("list-pins");
|
||||
}
|
||||
if (params.gate("profile")) {
|
||||
actions.add("set-profile");
|
||||
}
|
||||
if (params.gate("memberInfo")) {
|
||||
actions.add("member-info");
|
||||
}
|
||||
if (params.gate("channelInfo")) {
|
||||
actions.add("channel-info");
|
||||
}
|
||||
if (params.encryptionEnabled && params.gate("verification")) {
|
||||
actions.add("permissions");
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildMatrixProfileToolSchema(): NonNullable<ChannelMessageToolDiscovery["schema"]> {
|
||||
return {
|
||||
properties: {
|
||||
displayName: Type.Optional(
|
||||
Type.String({
|
||||
description: "Profile display name for Matrix self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
display_name: Type.Optional(
|
||||
Type.String({
|
||||
description: "snake_case alias of displayName for Matrix self-profile update actions.",
|
||||
}),
|
||||
),
|
||||
avatarUrl: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Profile avatar URL for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
avatar_url: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"snake_case alias of avatarUrl for Matrix self-profile update actions. Matrix accepts mxc:// and http(s) URLs.",
|
||||
}),
|
||||
),
|
||||
avatarPath: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Local avatar file path for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
|
||||
}),
|
||||
),
|
||||
avatar_path: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"snake_case alias of avatarPath for Matrix self-profile update actions. Matrix uploads this file and sets the resulting MXC URI.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
if (requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return { actions: [], capabilities: [] };
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolveDefaultMatrixAccountId(resolvedCfg),
|
||||
});
|
||||
if (!account.enabled || !account.configured) {
|
||||
return null;
|
||||
return { actions: [], capabilities: [] };
|
||||
}
|
||||
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
||||
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");
|
||||
}
|
||||
return { actions: Array.from(actions) };
|
||||
const gate = createActionGate(account.config.actions);
|
||||
const actions = createMatrixExposedActions({
|
||||
gate,
|
||||
encryptionEnabled: account.config.encryption === true,
|
||||
});
|
||||
const listedActions = Array.from(actions);
|
||||
return {
|
||||
actions: listedActions,
|
||||
capabilities: [],
|
||||
schema: listedActions.includes("set-profile") ? buildMatrixProfileToolSchema() : null,
|
||||
};
|
||||
},
|
||||
supportsAction: ({ action }) => action !== "poll",
|
||||
supportsAction: ({ action }) => MATRIX_PLUGIN_HANDLED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") {
|
||||
@@ -54,7 +144,17 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
return { to };
|
||||
},
|
||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||
const { action, params, cfg } = ctx;
|
||||
const { handleMatrixAction } = await import("./tool-actions.runtime.js");
|
||||
const { action, params, cfg, accountId, mediaLocalRoots } = ctx;
|
||||
const dispatch = async (actionParams: Record<string, unknown>) =>
|
||||
await handleMatrixAction(
|
||||
{
|
||||
...actionParams,
|
||||
...(accountId ? { accountId } : {}),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
{ mediaLocalRoots },
|
||||
);
|
||||
const resolveRoomId = () =>
|
||||
readStringParam(params, "roomId") ??
|
||||
readStringParam(params, "channelId") ??
|
||||
@@ -62,94 +162,83 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
required: !mediaUrl,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const threadId = readStringParam(params, "threadId");
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "sendMessage",
|
||||
to,
|
||||
content,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
replyToId: replyTo ?? undefined,
|
||||
threadId: threadId ?? undefined,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
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 handleMatrixAction(
|
||||
{
|
||||
action: "react",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
emoji,
|
||||
remove,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
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 handleMatrixAction(
|
||||
{
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
return await dispatch({
|
||||
action: "reactions",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
roomId: resolveRoomId(),
|
||||
limit,
|
||||
before: readStringParam(params, "before"),
|
||||
after: readStringParam(params, "after"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
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 handleMatrixAction(
|
||||
{
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
return await dispatch({
|
||||
action: "editMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
return await dispatch({
|
||||
action: "deleteMessage",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "pin" || action === "unpin" || action === "list-pins") {
|
||||
@@ -157,37 +246,81 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
action === "list-pins"
|
||||
? undefined
|
||||
: readStringParam(params, "messageId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action:
|
||||
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
return await dispatch({
|
||||
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
|
||||
roomId: resolveRoomId(),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "set-profile") {
|
||||
const avatarPath =
|
||||
readStringParam(params, "avatarPath") ??
|
||||
readStringParam(params, "path") ??
|
||||
readStringParam(params, "filePath");
|
||||
return await dispatch({
|
||||
action: "setProfile",
|
||||
displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"),
|
||||
avatarUrl: readStringParam(params, "avatarUrl"),
|
||||
avatarPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "member-info") {
|
||||
const userId = readStringParam(params, "userId", { required: true });
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
return await dispatch({
|
||||
action: "memberInfo",
|
||||
userId,
|
||||
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "channel-info") {
|
||||
return await handleMatrixAction(
|
||||
{
|
||||
action: "channelInfo",
|
||||
roomId: resolveRoomId(),
|
||||
},
|
||||
cfg as CoreConfig,
|
||||
);
|
||||
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.`);
|
||||
|
||||
61
extensions/matrix/src/auth-precedence.ts
Normal file
61
extensions/matrix/src/auth-precedence.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
export type MatrixResolvedStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
export type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
|
||||
|
||||
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
|
||||
|
||||
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
]);
|
||||
|
||||
function resolveMatrixStringSourceValue(value: string | undefined): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
|
||||
return (
|
||||
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
|
||||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountStringValues(params: {
|
||||
accountId: string;
|
||||
account?: MatrixStringSourceMap;
|
||||
scopedEnv?: MatrixStringSourceMap;
|
||||
channel?: MatrixStringSourceMap;
|
||||
globalEnv?: MatrixStringSourceMap;
|
||||
}): MatrixResolvedStringValues {
|
||||
const fields: MatrixResolvedStringField[] = [
|
||||
"homeserver",
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
];
|
||||
const resolved = {} as MatrixResolvedStringValues;
|
||||
|
||||
for (const field of fields) {
|
||||
resolved[field] =
|
||||
resolveMatrixStringSourceValue(params.account?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
|
||||
(shouldAllowBaseAuthFallback(params.accountId, field)
|
||||
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
|
||||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
|
||||
: "");
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
90
extensions/matrix/src/channel.account-paths.test.ts
Normal file
90
extensions/matrix/src/channel.account-paths.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
|
||||
const probeMatrixMock = vi.hoisted(() => vi.fn());
|
||||
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./matrix/send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/send.js")>("./matrix/send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./matrix/probe.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/probe.js")>("./matrix/probe.js");
|
||||
return {
|
||||
...actual,
|
||||
probeMatrix: (...args: unknown[]) => probeMatrixMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./matrix/client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./matrix/client.js")>("./matrix/client.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const { matrixPlugin } = await import("./channel.js");
|
||||
|
||||
describe("matrix account path propagation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sendMessageMatrixMock.mockResolvedValue({
|
||||
messageId: "$sent",
|
||||
roomId: "!room:example.org",
|
||||
});
|
||||
probeMatrixMock.mockResolvedValue({
|
||||
ok: true,
|
||||
error: null,
|
||||
status: null,
|
||||
elapsedMs: 5,
|
||||
userId: "@poe:example.org",
|
||||
});
|
||||
resolveMatrixAuthMock.mockResolvedValue({
|
||||
accountId: "poe",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@poe:example.org",
|
||||
accessToken: "poe-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards accountId when notifying pairing approval", async () => {
|
||||
await matrixPlugin.pairing!.notifyApproval?.({
|
||||
cfg: {},
|
||||
id: "@user:example.org",
|
||||
accountId: "poe",
|
||||
});
|
||||
|
||||
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
|
||||
"user:@user:example.org",
|
||||
expect.any(String),
|
||||
{ accountId: "poe" },
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards accountId to matrix probes", async () => {
|
||||
await matrixPlugin.status!.probeAccount?.({
|
||||
cfg: {} as never,
|
||||
timeoutMs: 500,
|
||||
account: {
|
||||
accountId: "poe",
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
accountId: "poe",
|
||||
});
|
||||
expect(probeMatrixMock).toHaveBeenCalledWith({
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "poe-token",
|
||||
userId: "@poe:example.org",
|
||||
timeoutMs: 500,
|
||||
accountId: "poe",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { resolveMatrixAccount } from "./matrix/accounts.js";
|
||||
import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import { createMatrixBotSdkMock } from "./test-mocks.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () =>
|
||||
createMatrixBotSdkMock({ includeVerboseLogService: true }),
|
||||
);
|
||||
|
||||
describe("matrix directory", () => {
|
||||
const runtimeEnv: RuntimeEnv = createRuntimeEnv();
|
||||
const runtimeEnv: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
@@ -103,6 +105,78 @@ describe("matrix directory", () => {
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("only exposes real Matrix thread ids in tool context", () => {
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
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?.({
|
||||
cfg: {} as CoreConfig,
|
||||
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?.({
|
||||
cfg: {} as CoreConfig,
|
||||
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("accepts raw room ids when inferring Matrix direct user ids", () => {
|
||||
expect(
|
||||
matrixPlugin.threading?.buildToolContext?.({
|
||||
cfg: {} as CoreConfig,
|
||||
context: {
|
||||
From: "user:@alice:example.org",
|
||||
To: "!dm:example.org",
|
||||
ChatType: "direct",
|
||||
},
|
||||
hasRepliedRef: { value: false },
|
||||
}),
|
||||
).toEqual({
|
||||
currentChannelId: "!dm:example.org",
|
||||
currentThreadTs: undefined,
|
||||
currentDirectUserId: "@alice:example.org",
|
||||
hasRepliedRef: { value: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves group mention policy from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -131,5 +205,406 @@ describe("matrix directory", () => {
|
||||
groupId: "!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
groupId: "matrix:room:!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches prefixed Matrix aliases in group context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"#ops:example.org": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(
|
||||
matrixPlugin.groups!.resolveRequireMention!({
|
||||
cfg,
|
||||
groupId: "matrix:room:!room:example.org",
|
||||
groupChannel: "matrix:channel:#ops:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports room access warnings against the active Matrix config path", () => {
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
|
||||
]);
|
||||
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
accounts: {
|
||||
assistant: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
accounts: {
|
||||
assistant: {
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "assistant",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.accounts.assistant.groupPolicy="allowlist" + channels.matrix.accounts.assistant.groups (and optionally channels.matrix.accounts.assistant.groupAllowFrom) to restrict rooms.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports invite auto-join warnings only when explicitly enabled", () => {
|
||||
expect(
|
||||
matrixPlugin.security?.collectWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
account: resolveMatrixAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
groupPolicy: "allowlist",
|
||||
autoJoin: "always",
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountId: "default",
|
||||
}),
|
||||
}),
|
||||
).toEqual([
|
||||
'- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set channels.matrix.autoJoin="allowlist" + channels.matrix.autoJoinAllowlist (or channels.matrix.autoJoin="off") to restrict joins.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes matrix non-default account credentials under channels.matrix.accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://default.example.org",
|
||||
accessToken: "default-token",
|
||||
deviceId: "DEFAULTDEVICE",
|
||||
avatarUrl: "mxc://server/avatar",
|
||||
encryption: true,
|
||||
threadReplies: "inbound",
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} 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"]?.accessToken).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.deviceId).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.avatarUrl).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({
|
||||
accessToken: "default-token",
|
||||
homeserver: "https://default.example.org",
|
||||
deviceId: "DEFAULTDEVICE",
|
||||
avatarUrl: "mxc://server/avatar",
|
||||
encryption: true,
|
||||
threadReplies: "inbound",
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
});
|
||||
expect(resolveMatrixConfigForAccount(updated, "ops", {})).toMatchObject({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes default matrix account credentials under channels.matrix.accounts.default", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
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"]).toMatchObject({
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "bot-token",
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts).toBeUndefined();
|
||||
});
|
||||
|
||||
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("clears stored auth fields when switching a Matrix account to env-backed auth", () => {
|
||||
const envKeys = {
|
||||
MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER,
|
||||
MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN,
|
||||
MATRIX_OPS_DEVICE_ID: process.env.MATRIX_OPS_DEVICE_ID,
|
||||
MATRIX_OPS_DEVICE_NAME: process.env.MATRIX_OPS_DEVICE_NAME,
|
||||
};
|
||||
process.env.MATRIX_OPS_HOMESERVER = "https://ops.env.example.org";
|
||||
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token";
|
||||
process.env.MATRIX_OPS_DEVICE_ID = "OPSENVDEVICE";
|
||||
process.env.MATRIX_OPS_DEVICE_NAME = "Ops Env Device";
|
||||
|
||||
try {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.inline.example.org",
|
||||
userId: "@ops:inline.example.org",
|
||||
accessToken: "ops-inline-token",
|
||||
password: "ops-inline-password", // pragma: allowlist secret
|
||||
deviceId: "OPSINLINEDEVICE",
|
||||
deviceName: "Ops Inline Device",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
const updated = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg,
|
||||
accountId: "ops",
|
||||
input: {
|
||||
useEnv: true,
|
||||
name: "Ops",
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
|
||||
name: "Ops",
|
||||
enabled: true,
|
||||
encryption: true,
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.homeserver).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.userId).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.accessToken).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.password).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.deviceId).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.deviceName).toBeUndefined();
|
||||
expect(resolveMatrixConfigForAccount(updated, "ops", process.env)).toMatchObject({
|
||||
homeserver: "https://ops.env.example.org",
|
||||
accessToken: "ops-env-token",
|
||||
deviceId: "OPSENVDEVICE",
|
||||
deviceName: "Ops Env Device",
|
||||
});
|
||||
} 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: {
|
||||
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", // pragma: allowlist secret
|
||||
},
|
||||
}) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBe("new-password");
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.accessToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears stale password when switching an account to token auth", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "old-password", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} 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"]?.accounts?.default?.accessToken).toBe("new-token");
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
41
extensions/matrix/src/channel.resolve.test.ts
Normal file
41
extensions/matrix/src/channel.resolve.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
|
||||
|
||||
vi.mock("./resolve-targets.js", () => ({
|
||||
resolveMatrixTargets: resolveMatrixTargetsMock,
|
||||
}));
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
|
||||
describe("matrix resolver adapter", () => {
|
||||
beforeEach(() => {
|
||||
resolveMatrixTargetsMock.mockClear();
|
||||
});
|
||||
|
||||
it("forwards accountId into Matrix target resolution", async () => {
|
||||
await matrixPlugin.resolver?.resolveTargets({
|
||||
cfg: { channels: { matrix: {} } },
|
||||
accountId: "ops",
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({
|
||||
cfg: { channels: { matrix: {} } },
|
||||
accountId: "ops",
|
||||
inputs: ["Alice"],
|
||||
kind: "user",
|
||||
runtime: expect.objectContaining({
|
||||
log: expect.any(Function),
|
||||
error: expect.any(Function),
|
||||
exit: expect.any(Function),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,14 @@
|
||||
import {
|
||||
listMatrixDirectoryGroupsLive as listMatrixDirectoryGroupsLiveImpl,
|
||||
listMatrixDirectoryPeersLive as listMatrixDirectoryPeersLiveImpl,
|
||||
} from "./directory-live.js";
|
||||
import { resolveMatrixAuth as resolveMatrixAuthImpl } from "./matrix/client.js";
|
||||
import { probeMatrix as probeMatrixImpl } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix as sendMessageMatrixImpl } from "./matrix/send.js";
|
||||
import { matrixOutbound as matrixOutboundImpl } from "./outbound.js";
|
||||
import { resolveMatrixTargets as resolveMatrixTargetsImpl } from "./resolve-targets.js";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
export const matrixChannelRuntime = {
|
||||
listMatrixDirectoryGroupsLive: listMatrixDirectoryGroupsLiveImpl,
|
||||
listMatrixDirectoryPeersLive: listMatrixDirectoryPeersLiveImpl,
|
||||
resolveMatrixAuth: resolveMatrixAuthImpl,
|
||||
probeMatrix: probeMatrixImpl,
|
||||
sendMessageMatrix: sendMessageMatrixImpl,
|
||||
resolveMatrixTargets: resolveMatrixTargetsImpl,
|
||||
matrixOutbound: { ...matrixOutboundImpl },
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
probeMatrix,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixTargets,
|
||||
sendMessageMatrix,
|
||||
};
|
||||
|
||||
253
extensions/matrix/src/channel.setup.test.ts
Normal file
253
extensions/matrix/src/channel.setup.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const verificationMocks = vi.hoisted(() => ({
|
||||
bootstrapMatrixVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: verificationMocks.bootstrapMatrixVerification,
|
||||
}));
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("matrix setup post-write bootstrap", () => {
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log,
|
||||
error,
|
||||
exit,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
verificationMocks.bootstrapMatrixVerification.mockReset();
|
||||
log.mockClear();
|
||||
error.mockClear();
|
||||
exit.mockClear();
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("bootstraps verification for newly added encrypted accounts", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "7",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not bootstrap verification for already configured accounts", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
flurry: {
|
||||
encryption: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "new-token",
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "flurry",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "flurry",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).not.toHaveBeenCalled();
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a warning when verification bootstrap fails", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: false,
|
||||
error: "no room-key backup exists on the homeserver",
|
||||
verification: {
|
||||
backupVersion: null,
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Matrix verification bootstrap warning for "default": no room-key backup exists on the homeserver',
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps a newly added env-backed default account when encryption is already enabled", async () => {
|
||||
const previousEnv = {
|
||||
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
|
||||
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
|
||||
};
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "env-token";
|
||||
try {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const input = {
|
||||
useEnv: true,
|
||||
};
|
||||
const nextCfg = matrixPlugin.setup!.applyAccountConfig({
|
||||
cfg: previousCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
verificationMocks.bootstrapMatrixVerification.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "9",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
|
||||
await matrixPlugin.setup!.afterAccountConfigWritten?.({
|
||||
previousCfg,
|
||||
cfg: nextCfg,
|
||||
accountId: "default",
|
||||
input,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects default useEnv setup when no Matrix auth env vars are available", () => {
|
||||
const previousEnv = {
|
||||
MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER,
|
||||
MATRIX_USER_ID: process.env.MATRIX_USER_ID,
|
||||
MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN,
|
||||
MATRIX_PASSWORD: process.env.MATRIX_PASSWORD,
|
||||
MATRIX_DEFAULT_HOMESERVER: process.env.MATRIX_DEFAULT_HOMESERVER,
|
||||
MATRIX_DEFAULT_USER_ID: process.env.MATRIX_DEFAULT_USER_ID,
|
||||
MATRIX_DEFAULT_ACCESS_TOKEN: process.env.MATRIX_DEFAULT_ACCESS_TOKEN,
|
||||
MATRIX_DEFAULT_PASSWORD: process.env.MATRIX_DEFAULT_PASSWORD,
|
||||
};
|
||||
for (const key of Object.keys(previousEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
try {
|
||||
expect(
|
||||
matrixPlugin.setup!.validateInput?.({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "default",
|
||||
input: { useEnv: true },
|
||||
}),
|
||||
).toContain("Set Matrix env vars for the default account");
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(previousEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
createTextPairingAdapter,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
@@ -39,6 +39,11 @@ import {
|
||||
type ResolvedMatrixAccount,
|
||||
} from "./matrix/accounts.js";
|
||||
import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js";
|
||||
import {
|
||||
normalizeMatrixMessagingTarget,
|
||||
resolveMatrixDirectUserId,
|
||||
resolveMatrixTargetIdentity,
|
||||
} from "./matrix/target-ids.js";
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
@@ -64,19 +69,6 @@ const meta = {
|
||||
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;
|
||||
}
|
||||
|
||||
const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
||||
ResolvedMatrixAccount,
|
||||
ReturnType<typeof resolveMatrixAccountConfig>,
|
||||
@@ -94,7 +86,9 @@ const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
||||
"userId",
|
||||
"accessToken",
|
||||
"password",
|
||||
"deviceId",
|
||||
"deviceName",
|
||||
"avatarUrl",
|
||||
"initialSyncLimit",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
||||
@@ -121,6 +115,78 @@ const collectMatrixSecurityWarnings =
|
||||
},
|
||||
});
|
||||
|
||||
function resolveMatrixAccountConfigPath(accountId: string, field: string): string {
|
||||
return accountId === DEFAULT_ACCOUNT_ID
|
||||
? `channels.matrix.${field}`
|
||||
: `channels.matrix.accounts.${accountId}.${field}`;
|
||||
}
|
||||
|
||||
function collectMatrixSecurityWarningsForAccount(params: {
|
||||
account: ResolvedMatrixAccount;
|
||||
cfg: CoreConfig;
|
||||
}): string[] {
|
||||
const warnings = collectMatrixSecurityWarnings(params);
|
||||
if (params.account.accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const groupPolicyPath = resolveMatrixAccountConfigPath(params.account.accountId, "groupPolicy");
|
||||
const groupsPath = resolveMatrixAccountConfigPath(params.account.accountId, "groups");
|
||||
const groupAllowFromPath = resolveMatrixAccountConfigPath(
|
||||
params.account.accountId,
|
||||
"groupAllowFrom",
|
||||
);
|
||||
return warnings.map((warning) =>
|
||||
warning
|
||||
.replace("channels.matrix.groupPolicy", groupPolicyPath)
|
||||
.replace("channels.matrix.groups", groupsPath)
|
||||
.replace("channels.matrix.groupAllowFrom", groupAllowFromPath),
|
||||
);
|
||||
}
|
||||
if (params.account.config.autoJoin !== "always") {
|
||||
return warnings;
|
||||
}
|
||||
const autoJoinPath = resolveMatrixAccountConfigPath(params.account.accountId, "autoJoin");
|
||||
const autoJoinAllowlistPath = resolveMatrixAccountConfigPath(
|
||||
params.account.accountId,
|
||||
"autoJoinAllowlist",
|
||||
);
|
||||
return [
|
||||
...warnings,
|
||||
`- Matrix invites: autoJoin="always" joins any invited room before message policy applies. Set ${autoJoinPath}="allowlist" + ${autoJoinAllowlistPath} (or ${autoJoinPath}="off") to restrict joins.`,
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeMatrixAcpConversationId(conversationId: string) {
|
||||
const target = resolveMatrixTargetIdentity(conversationId);
|
||||
if (!target || target.kind !== "room") {
|
||||
return null;
|
||||
}
|
||||
return { conversationId: target.id };
|
||||
}
|
||||
|
||||
function matchMatrixAcpConversation(params: {
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) {
|
||||
const binding = normalizeMatrixAcpConversationId(params.bindingConversationId);
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
if (binding.conversationId === params.conversationId) {
|
||||
return { conversationId: params.conversationId, matchPriority: 2 };
|
||||
}
|
||||
if (
|
||||
params.parentConversationId &&
|
||||
params.parentConversationId !== params.conversationId &&
|
||||
binding.conversationId === params.parentConversationId
|
||||
) {
|
||||
return {
|
||||
conversationId: params.parentConversationId,
|
||||
matchPriority: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
@@ -129,9 +195,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
idLabel: "matrixUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i),
|
||||
notify: async ({ id, message }) => {
|
||||
notify: async ({ id, message, accountId }) => {
|
||||
const { sendMessageMatrix } = await loadMatrixChannelRuntime();
|
||||
await sendMessageMatrix(`user:${id}`, message);
|
||||
await sendMessageMatrix(`user:${id}`, message, {
|
||||
...(accountId ? { accountId } : {}),
|
||||
});
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
@@ -161,7 +229,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
account,
|
||||
cfg: cfg as CoreConfig,
|
||||
}),
|
||||
collectMatrixSecurityWarnings,
|
||||
collectMatrixSecurityWarningsForAccount,
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
@@ -179,7 +247,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
return {
|
||||
currentChannelId: currentTarget?.trim() || undefined,
|
||||
currentThreadTs:
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
||||
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
|
||||
currentDirectUserId: resolveMatrixDirectUserId({
|
||||
from: context.From,
|
||||
to: context.To,
|
||||
chatType: context.ChatType,
|
||||
}),
|
||||
hasRepliedRef,
|
||||
};
|
||||
},
|
||||
@@ -259,8 +332,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
}),
|
||||
}),
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
|
||||
(await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }),
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) =>
|
||||
(await loadMatrixChannelRuntime()).resolveMatrixTargets({
|
||||
cfg,
|
||||
accountId,
|
||||
inputs,
|
||||
kind,
|
||||
runtime,
|
||||
}),
|
||||
},
|
||||
actions: matrixMessageActions,
|
||||
setup: matrixSetupAdapter,
|
||||
@@ -285,6 +364,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
}),
|
||||
},
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) =>
|
||||
normalizeMatrixAcpConversationId(conversationId),
|
||||
matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) =>
|
||||
matchMatrixAcpConversation({
|
||||
bindingConversationId: compiledBinding.conversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
@@ -308,6 +397,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
accessToken: auth.accessToken,
|
||||
userId: auth.userId,
|
||||
timeoutMs,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
977
extensions/matrix/src/cli.test.ts
Normal file
977
extensions/matrix/src/cli.test.ts
Normal file
@@ -0,0 +1,977 @@
|
||||
import { Command } from "commander";
|
||||
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const bootstrapMatrixVerificationMock = vi.fn();
|
||||
const getMatrixRoomKeyBackupStatusMock = vi.fn();
|
||||
const getMatrixVerificationStatusMock = vi.fn();
|
||||
const listMatrixOwnDevicesMock = vi.fn();
|
||||
const pruneMatrixStaleGatewayDevicesMock = vi.fn();
|
||||
const resolveMatrixAccountConfigMock = vi.fn();
|
||||
const resolveMatrixAccountMock = vi.fn();
|
||||
const resolveMatrixAuthContextMock = vi.fn();
|
||||
const matrixSetupApplyAccountConfigMock = vi.fn();
|
||||
const matrixSetupValidateInputMock = vi.fn();
|
||||
const matrixRuntimeLoadConfigMock = vi.fn();
|
||||
const matrixRuntimeWriteConfigFileMock = vi.fn();
|
||||
const resetMatrixRoomKeyBackupMock = vi.fn();
|
||||
const restoreMatrixRoomKeyBackupMock = vi.fn();
|
||||
const setMatrixSdkConsoleLoggingMock = vi.fn();
|
||||
const setMatrixSdkLogModeMock = vi.fn();
|
||||
const updateMatrixOwnProfileMock = vi.fn();
|
||||
const verifyMatrixRecoveryKeyMock = vi.fn();
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
||||
getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args),
|
||||
getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args),
|
||||
resetMatrixRoomKeyBackup: (...args: unknown[]) => resetMatrixRoomKeyBackupMock(...args),
|
||||
restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args),
|
||||
verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/devices.js", () => ({
|
||||
listMatrixOwnDevices: (...args: unknown[]) => listMatrixOwnDevicesMock(...args),
|
||||
pruneMatrixStaleGatewayDevices: (...args: unknown[]) =>
|
||||
pruneMatrixStaleGatewayDevicesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/client/logging.js", () => ({
|
||||
setMatrixSdkConsoleLogging: (...args: unknown[]) => setMatrixSdkConsoleLoggingMock(...args),
|
||||
setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/actions/profile.js", () => ({
|
||||
updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/accounts.js", () => ({
|
||||
resolveMatrixAccount: (...args: unknown[]) => resolveMatrixAccountMock(...args),
|
||||
resolveMatrixAccountConfig: (...args: unknown[]) => resolveMatrixAccountConfigMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuthContext: (...args: unknown[]) => resolveMatrixAuthContextMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./setup-core.js", () => ({
|
||||
matrixSetupAdapter: {
|
||||
applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args),
|
||||
validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args),
|
||||
writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerMatrixCli } = await import("./cli.js");
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
registerMatrixCli({ program });
|
||||
return program;
|
||||
}
|
||||
|
||||
function formatExpectedLocalTimestamp(value: string): string {
|
||||
return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value;
|
||||
}
|
||||
|
||||
describe("matrix CLI verification commands", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = undefined;
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
matrixSetupValidateInputMock.mockReturnValue(null);
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg);
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({});
|
||||
matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined);
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
|
||||
cfg,
|
||||
env: process.env,
|
||||
accountId: accountId ?? "default",
|
||||
resolved: {},
|
||||
}),
|
||||
);
|
||||
resolveMatrixAccountMock.mockReturnValue({
|
||||
configured: false,
|
||||
});
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: false,
|
||||
});
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
recoveryKeyCreatedAt: null,
|
||||
backupVersion: null,
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
resetMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: true,
|
||||
previousVersion: "1",
|
||||
deletedVersion: "1",
|
||||
createdVersion: "2",
|
||||
backup: {
|
||||
serverVersion: "2",
|
||||
activeVersion: "2",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
});
|
||||
updateMatrixOwnProfileMock.mockResolvedValue({
|
||||
skipped: false,
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: false,
|
||||
resolvedAvatarUrl: null,
|
||||
convertedAvatarFromHttp: false,
|
||||
});
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([]);
|
||||
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
|
||||
before: [],
|
||||
staleGatewayDeviceIds: [],
|
||||
currentDeviceId: null,
|
||||
deletedDeviceIds: [],
|
||||
remainingDevices: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for device verification failures in JSON mode", async () => {
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "invalid key",
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "device", "bad-key", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for bootstrap failures in JSON mode", async () => {
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "bootstrap failed",
|
||||
verification: {},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: null,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup restore failures in JSON mode", async () => {
|
||||
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "missing backup key",
|
||||
backupVersion: null,
|
||||
imported: 0,
|
||||
total: 0,
|
||||
loadedFromSecretStorage: false,
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "restore", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup reset failures in JSON mode", async () => {
|
||||
resetMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: "reset failed",
|
||||
previousVersion: "1",
|
||||
deletedVersion: "1",
|
||||
createdVersion: null,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("lists matrix devices", async () => {
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: "127.0.0.1",
|
||||
lastSeenTs: 1_741_507_200_000,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
]);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "devices", "list", "--account", "poe"], { from: "user" });
|
||||
|
||||
expect(listMatrixOwnDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
|
||||
expect(console.log).toHaveBeenCalledWith("Account: poe");
|
||||
expect(console.log).toHaveBeenCalledWith("- A7hWrQ70ea (current, OpenClaw Gateway)");
|
||||
expect(console.log).toHaveBeenCalledWith(" Last IP: 127.0.0.1");
|
||||
expect(console.log).toHaveBeenCalledWith("- BritdXC6iL (OpenClaw Gateway)");
|
||||
});
|
||||
|
||||
it("prunes stale matrix gateway devices", async () => {
|
||||
pruneMatrixStaleGatewayDevicesMock.mockResolvedValue({
|
||||
before: [
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: "127.0.0.1",
|
||||
lastSeenTs: 1_741_507_200_000,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
],
|
||||
staleGatewayDeviceIds: ["BritdXC6iL"],
|
||||
currentDeviceId: "A7hWrQ70ea",
|
||||
deletedDeviceIds: ["BritdXC6iL"],
|
||||
remainingDevices: [
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: "127.0.0.1",
|
||||
lastSeenTs: 1_741_507_200_000,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "devices", "prune-stale", "--account", "poe"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(pruneMatrixStaleGatewayDevicesMock).toHaveBeenCalledWith({ accountId: "poe" });
|
||||
expect(console.log).toHaveBeenCalledWith("Deleted stale OpenClaw devices: BritdXC6iL");
|
||||
expect(console.log).toHaveBeenCalledWith("Current device: A7hWrQ70ea");
|
||||
expect(console.log).toHaveBeenCalledWith("Remaining devices: 1");
|
||||
});
|
||||
|
||||
it("adds a matrix account and prints a binding hint", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown> | undefined),
|
||||
matrix: {
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"Ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
input: expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix:ops",
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps verification for newly added encrypted accounts", async () => {
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: true,
|
||||
});
|
||||
listMatrixOwnDevicesMock.mockResolvedValue([
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
]);
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
|
||||
backupVersion: "7",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({ accountId: "ops" });
|
||||
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp("2026-03-09T06:00:00.000Z")}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Backup version: 7");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Matrix device hygiene warning: stale OpenClaw devices detected (BritdXC6iL). Run 'openclaw matrix devices prune-stale --account ops'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not bootstrap verification when updating an already configured account", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: true,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(bootstrapMatrixVerificationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns instead of failing when device-health probing fails after saving the account", async () => {
|
||||
listMatrixOwnDevicesMock.mockRejectedValue(new Error("homeserver unavailable"));
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops");
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Matrix device health warning: homeserver unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns device-health warnings in JSON mode without failing the account add command", async () => {
|
||||
listMatrixOwnDevicesMock.mockRejectedValue(new Error("homeserver unavailable"));
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@ops:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
"--json",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
deviceHealth: expect.objectContaining({
|
||||
currentDeviceId: null,
|
||||
staleOpenClawDeviceIds: [],
|
||||
error: "homeserver unavailable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses --name as fallback account id and prints account-scoped config path", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--name",
|
||||
"Main Bot",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--user-id",
|
||||
"@main:example.org",
|
||||
"--password",
|
||||
"secret",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixSetupValidateInputMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "main-bot",
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Saved matrix account: main-bot");
|
||||
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.main-bot");
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "main-bot",
|
||||
displayName: "Main Bot",
|
||||
}),
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix:main-bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets profile name and avatar via profile set command", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"profile",
|
||||
"set",
|
||||
"--account",
|
||||
"alerts",
|
||||
"--name",
|
||||
"Alerts Bot",
|
||||
"--avatar-url",
|
||||
"mxc://example/avatar",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "alerts",
|
||||
displayName: "Alerts Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
}),
|
||||
);
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(console.log).toHaveBeenCalledWith("Account: alerts");
|
||||
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.alerts");
|
||||
});
|
||||
|
||||
it("returns JSON errors for invalid account setup input", async () => {
|
||||
matrixSetupValidateInputMock.mockReturnValue("Matrix requires --homeserver");
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "account", "add", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"error": "Matrix requires --homeserver"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps zero exit code for successful bootstrap in JSON mode", async () => {
|
||||
process.exitCode = 0;
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("prints local timezone timestamps for verify status output in verbose mode", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Diagnostics:");
|
||||
expect(console.log).toHaveBeenCalledWith("Locally trusted: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Signed by owner: yes");
|
||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
const verifiedAt = "2026-02-25T20:14:00.000Z";
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
},
|
||||
crossSigning: {
|
||||
published: true,
|
||||
masterKeyPublished: true,
|
||||
selfSigningKeyPublished: true,
|
||||
userSigningKeyPublished: true,
|
||||
},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
verifyMatrixRecoveryKeyMock.mockResolvedValue({
|
||||
success: true,
|
||||
encryptionEnabled: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyId: "SSSS",
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
verifiedAt,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "bootstrap", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
await program.parseAsync(["matrix", "verify", "device", "valid-key", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
`Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps default output concise when verbose is not provided", async () => {
|
||||
const recoveryCreatedAt = "2026-02-25T20:10:11.000Z";
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "1",
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: recoveryCreatedAt,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
`Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`,
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0");
|
||||
expect(console.log).not.toHaveBeenCalledWith("Diagnostics:");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet");
|
||||
});
|
||||
|
||||
it("shows explicit backup issue in default status output", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
backup: {
|
||||
serverVersion: "5256",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.",
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes key load failure details in status output", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "5256",
|
||||
backup: {
|
||||
serverVersion: "5256",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: "secret storage key is not available",
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes backup reset guidance when the backup key does not match this device", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: "21868",
|
||||
backup: {
|
||||
serverVersion: "21868",
|
||||
activeVersion: "21868",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-03-09T14:40:00.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run 'openclaw matrix verify backup reset --yes'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires --yes before resetting the Matrix room-key backup", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Backup reset failed: Refusing to reset Matrix room-key backup without --yes",
|
||||
);
|
||||
});
|
||||
|
||||
it("resets the Matrix room-key backup when confirmed", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "reset", "--yes"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({ accountId: "default" });
|
||||
expect(console.log).toHaveBeenCalledWith("Reset success: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Previous backup version: 1");
|
||||
expect(console.log).toHaveBeenCalledWith("Deleted backup version: 1");
|
||||
expect(console.log).toHaveBeenCalledWith("Current backup version: 2");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
});
|
||||
|
||||
it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => {
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
|
||||
cfg,
|
||||
env: process.env,
|
||||
accountId: accountId ?? "assistant",
|
||||
resolved: {},
|
||||
}),
|
||||
);
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
backupVersion: null,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: false,
|
||||
recoveryKeyCreatedAt: null,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith({
|
||||
accountId: "assistant",
|
||||
includeRecoveryKey: false,
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Account: assistant");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run 'openclaw matrix verify device <key> --account assistant' to verify this device.",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run 'openclaw matrix verify bootstrap --account assistant' to create a room key backup.",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints backup health lines for verify backup status in verbose mode", async () => {
|
||||
getMatrixRoomKeyBackupStatusMock.mockResolvedValue({
|
||||
serverVersion: "2",
|
||||
activeVersion: null,
|
||||
trusted: true,
|
||||
matchesDecryptionKey: false,
|
||||
decryptionKeyCached: false,
|
||||
keyLoadAttempted: true,
|
||||
keyLoadError: null,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "status", "--verbose"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith("Backup server version: 2");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup active on this device: no");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes");
|
||||
});
|
||||
});
|
||||
1182
extensions/matrix/src/cli.ts
Normal file
1182
extensions/matrix/src/cli.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,32 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { z } from "zod";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "../runtime-api.js";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
messages: z.boolean().optional(),
|
||||
pins: z.boolean().optional(),
|
||||
profile: 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();
|
||||
|
||||
@@ -41,7 +56,9 @@ export const MatrixConfigSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
password: buildSecretInputSchema().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(),
|
||||
@@ -51,6 +68,14 @@ export const MatrixConfigSchema = z.object({
|
||||
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: AllowFromListSchema,
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./matrix/sdk/http-client.js", () => ({
|
||||
MatrixAuthedHttpClient: class {
|
||||
requestJson(params: unknown) {
|
||||
return requestJsonMock(params);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("matrix directory live", () => {
|
||||
const cfg = { channels: { matrix: {} } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveMatrixAuth).mockReset();
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
accountId: "assistant",
|
||||
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();
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
it("passes accountId to peer directory auth resolution", async () => {
|
||||
@@ -60,6 +63,7 @@ describe("matrix directory live", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no group results for empty query without resolving auth", async () => {
|
||||
@@ -70,16 +74,84 @@ describe("matrix directory live", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(resolveMatrixAuth).not.toHaveBeenCalled();
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves original casing for room IDs without :server suffix", async () => {
|
||||
const mixedCaseId = "!EonMPPbOuhntHEHgZ2dnBO-c_EglMaXlIh2kdo8cgiA";
|
||||
const result = await listMatrixDirectoryGroupsLive({
|
||||
it("preserves query casing when searching the Matrix user directory", async () => {
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: mixedCaseId,
|
||||
query: "Alice",
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mixedCaseId);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
endpoint: "/_matrix/client/v3/user_directory/search",
|
||||
timeoutMs: 10_000,
|
||||
body: {
|
||||
search_term: "Alice",
|
||||
limit: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts prefixed fully qualified user ids without hitting Matrix", async () => {
|
||||
const results = await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
query: "matrix:user:@Alice:Example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
id: "@Alice:Example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves prefixed room aliases through the hardened Matrix HTTP client", async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
room_id: "!team:example.org",
|
||||
});
|
||||
|
||||
const results = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "channel:#Team:Example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "!team:example.org",
|
||||
name: "#Team:Example.org",
|
||||
handle: "#Team:Example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/directory/room/%23Team%3AExample.org",
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts prefixed room ids without additional Matrix lookups", async () => {
|
||||
const results = await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: "matrix:room:!team:example.org",
|
||||
});
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
kind: "group",
|
||||
id: "!team:example.org",
|
||||
name: "!team:example.org",
|
||||
},
|
||||
]);
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ChannelDirectoryEntry } from "../runtime-api.js";
|
||||
import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { MatrixAuthedHttpClient } from "./matrix/sdk/http-client.js";
|
||||
import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js";
|
||||
|
||||
type MatrixUserResult = {
|
||||
user_id?: string;
|
||||
@@ -31,45 +33,39 @@ type MatrixDirectoryLiveParams = {
|
||||
|
||||
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;
|
||||
}
|
||||
const MATRIX_DIRECTORY_TIMEOUT_MS = 10_000;
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function resolveMatrixDirectoryLimit(limit?: number | null): number {
|
||||
return typeof limit === "number" && limit > 0 ? limit : 20;
|
||||
return typeof limit === "number" && Number.isFinite(limit) && limit > 0
|
||||
? Math.max(1, Math.floor(limit))
|
||||
: 20;
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<{ query: string; auth: MatrixResolvedAuth } | null> {
|
||||
function createMatrixDirectoryClient(auth: MatrixResolvedAuth): MatrixAuthedHttpClient {
|
||||
return new MatrixAuthedHttpClient(auth.homeserver, auth.accessToken);
|
||||
}
|
||||
|
||||
async function resolveMatrixDirectoryContext(params: MatrixDirectoryLiveParams): Promise<{
|
||||
auth: MatrixResolvedAuth;
|
||||
client: MatrixAuthedHttpClient;
|
||||
query: string;
|
||||
queryLower: string;
|
||||
} | 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 };
|
||||
return {
|
||||
auth,
|
||||
client: createMatrixDirectoryClient(auth),
|
||||
query,
|
||||
queryLower: query.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function createGroupDirectoryEntry(params: {
|
||||
@@ -85,6 +81,22 @@ function createGroupDirectoryEntry(params: {
|
||||
} satisfies ChannelDirectoryEntry;
|
||||
}
|
||||
|
||||
async function requestMatrixJson<T>(
|
||||
client: MatrixAuthedHttpClient,
|
||||
params: {
|
||||
method: "GET" | "POST";
|
||||
endpoint: string;
|
||||
body?: unknown;
|
||||
},
|
||||
): Promise<T> {
|
||||
return (await client.requestJson({
|
||||
method: params.method,
|
||||
endpoint: params.endpoint,
|
||||
body: params.body,
|
||||
timeoutMs: MATRIX_DIRECTORY_TIMEOUT_MS,
|
||||
})) as T;
|
||||
}
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(
|
||||
params: MatrixDirectoryLiveParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
@@ -92,14 +104,16 @@ export async function listMatrixDirectoryPeersLive(
|
||||
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",
|
||||
const directUserId = normalizeMatrixMessagingTarget(context.query);
|
||||
if (directUserId && isMatrixQualifiedUserId(directUserId)) {
|
||||
return [{ kind: "user", id: directUserId }];
|
||||
}
|
||||
|
||||
const res = await requestMatrixJson<MatrixUserDirectoryResponse>(context.client, {
|
||||
method: "POST",
|
||||
endpoint: "/_matrix/client/v3/user_directory/search",
|
||||
body: {
|
||||
search_term: query,
|
||||
search_term: context.query,
|
||||
limit: resolveMatrixDirectoryLimit(params.limit),
|
||||
},
|
||||
});
|
||||
@@ -122,15 +136,13 @@ export async function listMatrixDirectoryPeersLive(
|
||||
}
|
||||
|
||||
async function resolveMatrixRoomAlias(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
client: MatrixAuthedHttpClient,
|
||||
alias: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixAliasLookup>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
const res = await requestMatrixJson<MatrixAliasLookup>(client, {
|
||||
method: "GET",
|
||||
endpoint: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
|
||||
});
|
||||
return res.room_id?.trim() || null;
|
||||
} catch {
|
||||
@@ -139,15 +151,13 @@ async function resolveMatrixRoomAlias(
|
||||
}
|
||||
|
||||
async function fetchMatrixRoomName(
|
||||
homeserver: string,
|
||||
accessToken: string,
|
||||
client: MatrixAuthedHttpClient,
|
||||
roomId: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetchMatrixJson<MatrixRoomNameState>({
|
||||
homeserver,
|
||||
accessToken,
|
||||
path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
const res = await requestMatrixJson<MatrixRoomNameState>(client, {
|
||||
method: "GET",
|
||||
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`,
|
||||
});
|
||||
return res.name?.trim() || null;
|
||||
} catch {
|
||||
@@ -162,36 +172,32 @@ export async function listMatrixDirectoryGroupsLive(
|
||||
if (!context) {
|
||||
return [];
|
||||
}
|
||||
const { query, auth } = context;
|
||||
const { client, query, queryLower } = context;
|
||||
const limit = resolveMatrixDirectoryLimit(params.limit);
|
||||
const directTarget = normalizeMatrixMessagingTarget(query);
|
||||
|
||||
if (query.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||
if (directTarget?.startsWith("!")) {
|
||||
return [createGroupDirectoryEntry({ id: directTarget, name: directTarget })];
|
||||
}
|
||||
|
||||
if (directTarget?.startsWith("#")) {
|
||||
const roomId = await resolveMatrixRoomAlias(client, directTarget);
|
||||
if (!roomId) {
|
||||
return [];
|
||||
}
|
||||
return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })];
|
||||
return [createGroupDirectoryEntry({ id: roomId, name: directTarget, handle: directTarget })];
|
||||
}
|
||||
|
||||
if (query.startsWith("!")) {
|
||||
const originalId = params.query?.trim() ?? query;
|
||||
return [createGroupDirectoryEntry({ id: originalId, name: originalId })];
|
||||
}
|
||||
|
||||
const joined = await fetchMatrixJson<MatrixJoinedRoomsResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
path: "/_matrix/client/v3/joined_rooms",
|
||||
const joined = await requestMatrixJson<MatrixJoinedRoomsResponse>(client, {
|
||||
method: "GET",
|
||||
endpoint: "/_matrix/client/v3/joined_rooms",
|
||||
});
|
||||
const rooms = joined.joined_rooms ?? [];
|
||||
const rooms = (joined.joined_rooms ?? []).map((roomId) => roomId.trim()).filter(Boolean);
|
||||
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)) {
|
||||
const name = await fetchMatrixRoomName(client, roomId);
|
||||
if (!name || !name.toLowerCase().includes(queryLower)) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
|
||||
92
extensions/matrix/src/env-vars.ts
Normal file
92
extensions/matrix/src/env-vars.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { normalizeAccountId, normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
|
||||
const MATRIX_SCOPED_ENV_SUFFIXES = [
|
||||
"HOMESERVER",
|
||||
"USER_ID",
|
||||
"ACCESS_TOKEN",
|
||||
"PASSWORD",
|
||||
"DEVICE_ID",
|
||||
"DEVICE_NAME",
|
||||
] as const;
|
||||
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
|
||||
|
||||
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
|
||||
|
||||
export function resolveMatrixEnvAccountToken(accountId: string): string {
|
||||
return Array.from(normalizeAccountId(accountId))
|
||||
.map((char) =>
|
||||
/[a-z0-9]/.test(char)
|
||||
? char.toUpperCase()
|
||||
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
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`,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeMatrixEnvAccountToken(token: string): string | undefined {
|
||||
let decoded = "";
|
||||
for (let index = 0; index < token.length; ) {
|
||||
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
|
||||
if (hexEscape) {
|
||||
const hex = hexEscape[1];
|
||||
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
return undefined;
|
||||
}
|
||||
const char = String.fromCodePoint(codePoint);
|
||||
decoded += char;
|
||||
index += hexEscape[0].length;
|
||||
continue;
|
||||
}
|
||||
const char = token[index];
|
||||
if (!char || !/[A-Z0-9]/.test(char)) {
|
||||
return undefined;
|
||||
}
|
||||
decoded += char.toLowerCase();
|
||||
index += 1;
|
||||
}
|
||||
const normalized = normalizeOptionalAccountId(decoded);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
|
||||
if (typeof env[key] === "string" && env[key]?.trim()) {
|
||||
ids.add(normalizeAccountId("default"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(env)) {
|
||||
const match = MATRIX_SCOPED_ENV_RE.exec(key);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const accountId = decodeMatrixEnvAccountToken(match[1]);
|
||||
if (accountId) {
|
||||
ids.add(accountId);
|
||||
}
|
||||
}
|
||||
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js";
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import { normalizeMatrixResolvableTarget } from "./matrix/target-ids.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 roomId = normalizeMatrixResolvableTarget(params.groupId?.trim() ?? "");
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const aliases = groupChannel ? [normalizeMatrixResolvableTarget(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;
|
||||
}
|
||||
|
||||
|
||||
68
extensions/matrix/src/matrix/account-config.ts
Normal file
68
extensions/matrix/src/matrix/account-config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js";
|
||||
|
||||
export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig {
|
||||
return cfg.channels?.matrix ?? {};
|
||||
}
|
||||
|
||||
function resolveMatrixAccountsMap(cfg: CoreConfig): Readonly<Record<string, MatrixAccountConfig>> {
|
||||
const accounts = resolveMatrixBaseConfig(cfg).accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return {};
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export function listNormalizedMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
Object.keys(resolveMatrixAccountsMap(cfg))
|
||||
.filter(Boolean)
|
||||
.map((accountId) => normalizeAccountId(accountId)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function findMatrixAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): MatrixAccountConfig | undefined {
|
||||
const accounts = resolveMatrixAccountsMap(cfg);
|
||||
if (accounts[accountId] && typeof accounts[accountId] === "object") {
|
||||
return accounts[accountId];
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalized) {
|
||||
const candidate = accounts[key];
|
||||
if (candidate && typeof candidate === "object") {
|
||||
return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function hasExplicitMatrixAccountConfig(cfg: CoreConfig, accountId: string): boolean {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (findMatrixAccountConfig(cfg, normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (normalized !== DEFAULT_ACCOUNT_ID) {
|
||||
return false;
|
||||
}
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
return (
|
||||
typeof matrix.enabled === "boolean" ||
|
||||
typeof matrix.name === "string" ||
|
||||
typeof matrix.homeserver === "string" ||
|
||||
typeof matrix.userId === "string" ||
|
||||
typeof matrix.accessToken === "string" ||
|
||||
typeof matrix.password === "string" ||
|
||||
typeof matrix.deviceId === "string" ||
|
||||
typeof matrix.deviceName === "string" ||
|
||||
typeof matrix.avatarUrl === "string"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./accounts.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
@@ -13,6 +18,10 @@ const envKeys = [
|
||||
"MATRIX_ACCESS_TOKEN",
|
||||
"MATRIX_PASSWORD",
|
||||
"MATRIX_DEVICE_NAME",
|
||||
"MATRIX_DEFAULT_HOMESERVER",
|
||||
"MATRIX_DEFAULT_ACCESS_TOKEN",
|
||||
getMatrixScopedEnvVarNames("team-ops").homeserver,
|
||||
getMatrixScopedEnvVarNames("team-ops").accessToken,
|
||||
];
|
||||
|
||||
describe("resolveMatrixAccount", () => {
|
||||
@@ -79,48 +88,106 @@ describe("resolveMatrixAccount", () => {
|
||||
const account = resolveMatrixAccount({ cfg });
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultMatrixAccountId", () => {
|
||||
it("prefers channels.matrix.defaultAccount when it matches a configured account", () => {
|
||||
it("normalizes and de-duplicates configured account ids", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "alerts",
|
||||
defaultAccount: "Main Bot",
|
||||
accounts: {
|
||||
default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
|
||||
alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
|
||||
"Main Bot": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "main-token",
|
||||
},
|
||||
"main-bot": {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "duplicate-token",
|
||||
},
|
||||
OPS: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("alerts");
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("normalizes channels.matrix.defaultAccount before lookup", () => {
|
||||
it("returns the only named account when no explicit default is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "Team Alerts",
|
||||
accounts: {
|
||||
"team-alerts": { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-alerts");
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
|
||||
});
|
||||
|
||||
it("falls back when channels.matrix.defaultAccount is not configured", () => {
|
||||
it("includes env-backed named accounts in plugin account enumeration", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
process.env[keys.homeserver] = "https://matrix.example.org";
|
||||
process.env[keys.accessToken] = "ops-token";
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["team-ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-ops");
|
||||
});
|
||||
|
||||
it("includes default accounts backed only by global env vars in plugin account enumeration", () => {
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "default-token";
|
||||
|
||||
const cfg: CoreConfig = {};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["default"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("treats mixed default and named env-backed accounts as multi-account", () => {
|
||||
const keys = getMatrixScopedEnvVarNames("team-ops");
|
||||
process.env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
process.env.MATRIX_ACCESS_TOKEN = "default-token";
|
||||
process.env[keys.homeserver] = "https://matrix.example.org";
|
||||
process.env[keys.accessToken] = "ops-token";
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["default", "team-ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" },
|
||||
alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" },
|
||||
alpha: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "alpha-token",
|
||||
},
|
||||
beta: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "beta-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { hasConfiguredSecretInput } from "../secret-input.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
|
||||
import { resolveMatrixConfigForAccount } from "./client.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
|
||||
|
||||
@@ -18,7 +25,6 @@ function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixCo
|
||||
}
|
||||
// Don't propagate the accounts map into the merged per-account config
|
||||
delete (merged as Record<string, unknown>).accounts;
|
||||
delete (merged as Record<string, unknown>).defaultAccount;
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -32,29 +38,13 @@ export type ResolvedMatrixAccount = {
|
||||
config: MatrixConfig;
|
||||
};
|
||||
|
||||
const {
|
||||
listAccountIds: listMatrixAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultMatrixAccountId,
|
||||
} = createAccountListHelpers("matrix", { normalizeAccountId });
|
||||
export { listMatrixAccountIds, resolveDefaultMatrixAccountId };
|
||||
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = resolveConfiguredMatrixAccountIds(cfg, process.env);
|
||||
return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
|
||||
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
// Direct lookup first (fast path for already-normalized keys)
|
||||
if (accounts[accountId]) {
|
||||
return accounts[accountId] as MatrixConfig;
|
||||
}
|
||||
// Fall back to case-insensitive match (user may have mixed-case keys in config)
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalized) {
|
||||
return accounts[key] as MatrixConfig;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
@@ -62,7 +52,7 @@ export function resolveMatrixAccount(params: {
|
||||
accountId?: string | null;
|
||||
}): ResolvedMatrixAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
const matrixBase = resolveMatrixBaseConfig(params.cfg);
|
||||
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
|
||||
const enabled = base.enabled !== false && matrixBase.enabled !== false;
|
||||
|
||||
@@ -97,8 +87,8 @@ export function resolveMatrixAccountConfig(params: {
|
||||
accountId?: string | null;
|
||||
}): MatrixConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
const accountConfig = resolveAccountConfig(params.cfg, accountId);
|
||||
const matrixBase = resolveMatrixBaseConfig(params.cfg);
|
||||
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
|
||||
if (!accountConfig) {
|
||||
return matrixBase;
|
||||
}
|
||||
@@ -106,9 +96,3 @@ export function resolveMatrixAccountConfig(params: {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,29 @@ export {
|
||||
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,
|
||||
resetMatrixRoomKeyBackup,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
scanMatrixVerificationQr,
|
||||
startMatrixVerification,
|
||||
verifyMatrixRecoveryKey,
|
||||
} from "./actions/verification.js";
|
||||
export { reactMatrixMessage } from "./send.js";
|
||||
|
||||
227
extensions/matrix/src/matrix/actions/client.test.ts
Normal file
227
extensions/matrix/src/matrix/actions/client.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockMatrixClient,
|
||||
matrixClientResolverMocks,
|
||||
primeMatrixClientResolverMocks,
|
||||
} from "../client-resolver.test-helpers.js";
|
||||
|
||||
const resolveMatrixRoomIdMock = vi.fn();
|
||||
|
||||
const {
|
||||
loadConfigMock,
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
getActiveMatrixClient: getActiveMatrixClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
acquireSharedMatrixClient: acquireSharedMatrixClientMock,
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("../client/shared.js", () => ({
|
||||
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
|
||||
}));
|
||||
|
||||
const { withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
|
||||
await import("./client.js");
|
||||
|
||||
describe("action client helpers", () => {
|
||||
beforeEach(() => {
|
||||
primeMatrixClientResolverMocks();
|
||||
resolveMatrixRoomIdMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (_client, roomId: string) => roomId);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("stops one-off shared clients when no active monitor client is registered", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
timeoutMs: undefined,
|
||||
accountId: "default",
|
||||
startClient: false,
|
||||
});
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips one-off room preparation when readiness is disabled", async () => {
|
||||
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(sharedClient.start).not.toHaveBeenCalled();
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("starts one-off clients when started readiness is required", async () => {
|
||||
await withStartedActionClient({ accountId: "default" }, async () => {});
|
||||
|
||||
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
|
||||
expect(sharedClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "persist");
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts active clients when started readiness is required", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
});
|
||||
|
||||
expect(activeClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(activeClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
expect(activeClient.stopAndPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the implicit resolved account id for active client lookup and storage", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveMatrixAuthContextMock.mockReturnValue({
|
||||
cfg: loadConfigMock(),
|
||||
env: process.env,
|
||||
accountId: "ops",
|
||||
resolved: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
},
|
||||
});
|
||||
await withResolvedActionClient({}, async () => {});
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
|
||||
cfg: loadConfigMock(),
|
||||
timeoutMs: undefined,
|
||||
accountId: "ops",
|
||||
startClient: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit cfg instead of loading runtime config", async () => {
|
||||
const explicitCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({
|
||||
cfg: explicitCfg,
|
||||
timeoutMs: undefined,
|
||||
accountId: "ops",
|
||||
startClient: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops shared action clients after wrapped calls succeed", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("stops shared action clients when the wrapped call throws", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedActionClient({ accountId: "default" }, async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("resolves room ids before running wrapped room actions", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
resolveMatrixRoomIdMock.mockResolvedValue("!room:example.org");
|
||||
|
||||
const result = await withResolvedRoomAction(
|
||||
"room:#ops:example.org",
|
||||
{ accountId: "default" },
|
||||
async (client, resolvedRoom) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return resolvedRoom;
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveMatrixRoomIdMock).toHaveBeenCalledWith(sharedClient, "room:#ops:example.org");
|
||||
expect(result).toBe("!room:example.org");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,31 @@
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getActiveMatrixClient } from "../active-client.js";
|
||||
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export function ensureNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
type MatrixActionClientStopMode = "stop" | "persist";
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
return await withResolvedRuntimeMatrixClient(opts, run, mode);
|
||||
}
|
||||
|
||||
export async function resolveActionClient(
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<MatrixActionClient> {
|
||||
ensureNodeRuntime();
|
||||
if (opts.client) {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
// Normalize accountId early to ensure consistent keying across all lookups
|
||||
const accountId = normalizeAccountId(opts.accountId);
|
||||
const active = getActiveMatrixClient(accountId);
|
||||
if (active) {
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId,
|
||||
});
|
||||
const client = await createPreparedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: true };
|
||||
export async function withStartedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist");
|
||||
}
|
||||
|
||||
export async function withResolvedRoomAction<T>(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await run(client, resolvedRoom);
|
||||
});
|
||||
}
|
||||
|
||||
114
extensions/matrix/src/matrix/actions/devices.test.ts
Normal file
114
extensions/matrix/src/matrix/actions/devices.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const withStartedActionClientMock = vi.fn();
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
|
||||
}));
|
||||
|
||||
const { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } = await import("./devices.js");
|
||||
|
||||
describe("matrix device actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists own devices on a started client", async () => {
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
listOwnDevices: vi.fn(async () => [
|
||||
{
|
||||
deviceId: "A7hWrQ70ea",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await listMatrixOwnDevices({ accountId: "poe" });
|
||||
|
||||
expect(withStartedActionClientMock).toHaveBeenCalledWith(
|
||||
{ accountId: "poe" },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
deviceId: "A7hWrQ70ea",
|
||||
current: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("prunes stale OpenClaw-managed devices but preserves the current device", async () => {
|
||||
const deleteOwnDevices = vi.fn(async () => ({
|
||||
currentDeviceId: "du314Zpw3A",
|
||||
deletedDeviceIds: ["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"],
|
||||
remainingDevices: [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
listOwnDevices: vi.fn(async () => [
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "G6NJU9cTgs",
|
||||
displayName: "OpenClaw Debug",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "My3T0hkTE0",
|
||||
displayName: "OpenClaw Gateway",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "phone123",
|
||||
displayName: "Element iPhone",
|
||||
lastSeenIp: null,
|
||||
lastSeenTs: null,
|
||||
current: false,
|
||||
},
|
||||
]),
|
||||
deleteOwnDevices,
|
||||
});
|
||||
});
|
||||
|
||||
const result = await pruneMatrixStaleGatewayDevices({ accountId: "poe" });
|
||||
|
||||
expect(deleteOwnDevices).toHaveBeenCalledWith(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
|
||||
expect(result.staleGatewayDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
|
||||
expect(result.deletedDeviceIds).toEqual(["BritdXC6iL", "G6NJU9cTgs", "My3T0hkTE0"]);
|
||||
expect(result.remainingDevices).toEqual([
|
||||
expect.objectContaining({
|
||||
deviceId: "du314Zpw3A",
|
||||
current: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
extensions/matrix/src/matrix/actions/devices.ts
Normal file
34
extensions/matrix/src/matrix/actions/devices.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { summarizeMatrixDeviceHealth } from "../device-health.js";
|
||||
import { withStartedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function listMatrixOwnDevices(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => await client.listOwnDevices());
|
||||
}
|
||||
|
||||
export async function pruneMatrixStaleGatewayDevices(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const devices = await client.listOwnDevices();
|
||||
const health = summarizeMatrixDeviceHealth(devices);
|
||||
const staleGatewayDeviceIds = health.staleOpenClawDevices.map((device) => device.deviceId);
|
||||
const deleted =
|
||||
staleGatewayDeviceIds.length > 0
|
||||
? await client.deleteOwnDevices(staleGatewayDeviceIds)
|
||||
: {
|
||||
currentDeviceId: devices.find((device) => device.current)?.deviceId ?? null,
|
||||
deletedDeviceIds: [] as string[],
|
||||
remainingDevices: devices,
|
||||
};
|
||||
return {
|
||||
before: devices,
|
||||
staleGatewayDeviceIds,
|
||||
...deleted,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixDeviceHealth(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) =>
|
||||
summarizeMatrixDeviceHealth(await client.listOwnDevices()),
|
||||
);
|
||||
}
|
||||
228
extensions/matrix/src/matrix/actions/messages.test.ts
Normal file
228
extensions/matrix/src/matrix/actions/messages.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { readMatrixMessages } from "./messages.js";
|
||||
|
||||
function createMessagesClient(params: {
|
||||
chunk: Array<Record<string, unknown>>;
|
||||
hydratedChunk?: Array<Record<string, unknown>>;
|
||||
pollRoot?: Record<string, unknown>;
|
||||
pollRelations?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const doRequest = vi.fn(async () => ({
|
||||
chunk: params.chunk,
|
||||
start: "start-token",
|
||||
end: "end-token",
|
||||
}));
|
||||
const hydrateEvents = vi.fn(
|
||||
async (_roomId: string, _events: Array<Record<string, unknown>>) =>
|
||||
(params.hydratedChunk ?? params.chunk) as any,
|
||||
);
|
||||
const getEvent = vi.fn(async () => params.pollRoot ?? null);
|
||||
const getRelations = vi.fn(async () => ({
|
||||
events: params.pollRelations ?? [],
|
||||
nextBatch: null,
|
||||
prevBatch: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
client: {
|
||||
doRequest,
|
||||
hydrateEvents,
|
||||
getEvent,
|
||||
getRelations,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
doRequest,
|
||||
hydrateEvents,
|
||||
getEvent,
|
||||
getRelations,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix message actions", () => {
|
||||
it("includes poll snapshots when reading message history", async () => {
|
||||
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
|
||||
chunk: [
|
||||
{
|
||||
event_id: "$vote",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.poll.response",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
"m.poll.response": { answers: ["a1"] },
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "$msg",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 10,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
kind: "m.poll.disclosed",
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "a1", "m.text": "Apple" },
|
||||
{ id: "a2", "m.text": "Strawberry" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
pollRelations: [
|
||||
{
|
||||
event_id: "$vote",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.poll.response",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
"m.poll.response": { answers: ["a1"] },
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client, limit: 2.9 });
|
||||
|
||||
expect(doRequest).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
expect.stringContaining("/rooms/!room%3Aexample.org/messages"),
|
||||
expect.objectContaining({ limit: 2 }),
|
||||
);
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(getRelations).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"$poll",
|
||||
"m.reference",
|
||||
undefined,
|
||||
{
|
||||
from: undefined,
|
||||
},
|
||||
);
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
eventId: "$poll",
|
||||
body: expect.stringContaining("1. Apple (1 vote)"),
|
||||
msgtype: "m.text",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
eventId: "$msg",
|
||||
body: "hello",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes multiple poll events for the same poll within one read page", async () => {
|
||||
const { client, getEvent } = createMessagesClient({
|
||||
chunk: [
|
||||
{
|
||||
event_id: "$vote",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.poll.response",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
"m.poll.response": { answers: ["a1"] },
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
answers: [{ id: "a1", "m.text": "Apple" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
answers: [{ id: "a1", "m.text": "Apple" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
pollRelations: [],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
eventId: "$poll",
|
||||
body: expect.stringContaining("[Poll]"),
|
||||
}),
|
||||
);
|
||||
expect(getEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses hydrated history events so encrypted poll entries can be read", async () => {
|
||||
const { client, hydrateEvents } = createMessagesClient({
|
||||
chunk: [
|
||||
{
|
||||
event_id: "$enc",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.room.encrypted",
|
||||
origin_server_ts: 20,
|
||||
content: {},
|
||||
},
|
||||
],
|
||||
hydratedChunk: [
|
||||
{
|
||||
event_id: "$vote",
|
||||
sender: "@bob:example.org",
|
||||
type: "m.poll.response",
|
||||
origin_server_ts: 20,
|
||||
content: {
|
||||
"m.poll.response": { answers: ["a1"] },
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$poll" },
|
||||
},
|
||||
},
|
||||
],
|
||||
pollRoot: {
|
||||
event_id: "$poll",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.poll.start",
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
answers: [{ id: "a1", "m.text": "Apple" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
pollRelations: [],
|
||||
});
|
||||
|
||||
const result = await readMatrixMessages("room:!room:example.org", { client });
|
||||
|
||||
expect(hydrateEvents).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
expect.arrayContaining([expect.objectContaining({ event_id: "$enc" })]),
|
||||
);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages[0]?.eventId).toBe("$poll");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
|
||||
import { isPollEventType } from "../poll-types.js";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
import {
|
||||
@@ -14,7 +16,7 @@ import {
|
||||
|
||||
export async function sendMatrixMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
content: string | undefined,
|
||||
opts: MatrixActionClientOpts & {
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
@@ -22,9 +24,12 @@ export async function sendMatrixMessage(
|
||||
} = {},
|
||||
) {
|
||||
return await sendMessageMatrix(to, content, {
|
||||
cfg: opts.cfg,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
mediaLocalRoots: opts.mediaLocalRoots,
|
||||
replyToId: opts.replyToId,
|
||||
threadId: opts.threadId,
|
||||
accountId: opts.accountId ?? undefined,
|
||||
client: opts.client,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
@@ -40,9 +45,7 @@ export async function editMatrixMessage(
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix edit requires content");
|
||||
}
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const newContent = {
|
||||
msgtype: MsgType.Text,
|
||||
body: trimmed,
|
||||
@@ -58,11 +61,7 @@ export async function editMatrixMessage(
|
||||
};
|
||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||
return { eventId: eventId ?? null };
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMatrixMessage(
|
||||
@@ -70,15 +69,9 @@ export async function deleteMatrixMessage(
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function readMatrixMessages(
|
||||
@@ -93,13 +86,11 @@ export async function readMatrixMessages(
|
||||
nextBatch?: string | null;
|
||||
prevBatch?: string | null;
|
||||
}> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 20);
|
||||
const token = opts.before?.trim() || opts.after?.trim() || undefined;
|
||||
const dir = opts.after ? "f" : "b";
|
||||
// @vector-im/matrix-bot-sdk uses doRequest for room messages
|
||||
// Room history is queried via the low-level endpoint for compatibility.
|
||||
const res = (await client.doRequest(
|
||||
"GET",
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
|
||||
@@ -109,18 +100,34 @@ export async function readMatrixMessages(
|
||||
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);
|
||||
const hydratedChunk = await client.hydrateEvents(resolvedRoom, res.chunk);
|
||||
const seenPollRoots = new Set<string>();
|
||||
const messages: MatrixMessageSummary[] = [];
|
||||
for (const event of hydratedChunk) {
|
||||
if (event.unsigned?.redacted_because) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === EventType.RoomMessage) {
|
||||
messages.push(summarizeMatrixRawEvent(event));
|
||||
continue;
|
||||
}
|
||||
if (!isPollEventType(event.type)) {
|
||||
continue;
|
||||
}
|
||||
const pollRootId = resolveMatrixPollRootEventId(event);
|
||||
if (!pollRootId || seenPollRoots.has(pollRootId)) {
|
||||
continue;
|
||||
}
|
||||
seenPollRoots.add(pollRootId);
|
||||
const pollSummary = await fetchMatrixPollMessageSummary(client, resolvedRoom, event);
|
||||
if (pollSummary) {
|
||||
messages.push(pollSummary);
|
||||
}
|
||||
}
|
||||
return {
|
||||
messages,
|
||||
nextBatch: res.end ?? null,
|
||||
prevBatch: res.start ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
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> = {}) {
|
||||
|
||||
@@ -1,39 +1,19 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { withResolvedRoomAction } 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> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await run(client, resolvedRoom);
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMatrixPins(
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts,
|
||||
update: (current: string[]) => string[],
|
||||
): Promise<{ pinned: string[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const current = await readPinnedEvents(client, resolvedRoom);
|
||||
const next = update(current);
|
||||
const payload: RoomPinnedEventsEventContent = { pinned: next };
|
||||
@@ -66,7 +46,7 @@ export async function listMatrixPins(
|
||||
roomId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> {
|
||||
return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const pinned = await readPinnedEvents(client, resolvedRoom);
|
||||
const events = (
|
||||
await Promise.all(
|
||||
|
||||
71
extensions/matrix/src/matrix/actions/polls.test.ts
Normal file
71
extensions/matrix/src/matrix/actions/polls.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { voteMatrixPoll } from "./polls.js";
|
||||
|
||||
function createPollClient(pollContent?: Record<string, unknown>) {
|
||||
const getEvent = vi.fn(async () => ({
|
||||
type: "m.poll.start",
|
||||
content: pollContent ?? {
|
||||
"m.poll.start": {
|
||||
question: { "m.text": "Favorite fruit?" },
|
||||
max_selections: 1,
|
||||
answers: [
|
||||
{ id: "apple", "m.text": "Apple" },
|
||||
{ id: "berry", "m.text": "Berry" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
const sendEvent = vi.fn(async () => "$vote1");
|
||||
|
||||
return {
|
||||
client: {
|
||||
getEvent,
|
||||
sendEvent,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getEvent,
|
||||
sendEvent,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix poll actions", () => {
|
||||
it("votes by option index against the resolved room id", async () => {
|
||||
const { client, getEvent, sendEvent } = createPollClient();
|
||||
|
||||
const result = await voteMatrixPoll("room:!room:example.org", "$poll", {
|
||||
client,
|
||||
optionIndex: 2,
|
||||
});
|
||||
|
||||
expect(getEvent).toHaveBeenCalledWith("!room:example.org", "$poll");
|
||||
expect(sendEvent).toHaveBeenCalledWith(
|
||||
"!room:example.org",
|
||||
"m.poll.response",
|
||||
expect.objectContaining({
|
||||
"m.poll.response": { answers: ["berry"] },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
eventId: "$vote1",
|
||||
roomId: "!room:example.org",
|
||||
pollId: "$poll",
|
||||
answerIds: ["berry"],
|
||||
labels: ["Berry"],
|
||||
maxSelections: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects option indexes that are outside the poll range", async () => {
|
||||
const { client, sendEvent } = createPollClient();
|
||||
|
||||
await expect(
|
||||
voteMatrixPoll("room:!room:example.org", "$poll", {
|
||||
client,
|
||||
optionIndex: 3,
|
||||
}),
|
||||
).rejects.toThrow("out of range");
|
||||
|
||||
expect(sendEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
109
extensions/matrix/src/matrix/actions/polls.ts
Normal file
109
extensions/matrix/src/matrix/actions/polls.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
buildPollResponseContent,
|
||||
isPollStartType,
|
||||
parsePollStart,
|
||||
type PollStartContent,
|
||||
} from "../poll-types.js";
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
function normalizeOptionIndexes(indexes: number[]): number[] {
|
||||
const normalized = indexes
|
||||
.map((index) => Math.trunc(index))
|
||||
.filter((index) => Number.isFinite(index) && index > 0);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
function normalizeOptionIds(optionIds: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(optionIds.map((optionId) => optionId.trim()).filter((optionId) => optionId.length > 0)),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedAnswerIds(params: {
|
||||
optionIds?: string[];
|
||||
optionIndexes?: number[];
|
||||
pollContent: PollStartContent;
|
||||
}): { answerIds: string[]; labels: string[]; maxSelections: number } {
|
||||
const parsed = parsePollStart(params.pollContent);
|
||||
if (!parsed) {
|
||||
throw new Error("Matrix poll vote requires a valid poll start event.");
|
||||
}
|
||||
|
||||
const selectedById = normalizeOptionIds(params.optionIds ?? []);
|
||||
const selectedByIndex = normalizeOptionIndexes(params.optionIndexes ?? []).map((index) => {
|
||||
const answer = parsed.answers[index - 1];
|
||||
if (!answer) {
|
||||
throw new Error(
|
||||
`Matrix poll option index ${index} is out of range for a poll with ${parsed.answers.length} options.`,
|
||||
);
|
||||
}
|
||||
return answer.id;
|
||||
});
|
||||
|
||||
const answerIds = normalizeOptionIds([...selectedById, ...selectedByIndex]);
|
||||
if (answerIds.length === 0) {
|
||||
throw new Error("Matrix poll vote requires at least one poll option id or index.");
|
||||
}
|
||||
if (answerIds.length > parsed.maxSelections) {
|
||||
throw new Error(
|
||||
`Matrix poll allows at most ${parsed.maxSelections} selection${parsed.maxSelections === 1 ? "" : "s"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const answerMap = new Map(parsed.answers.map((answer) => [answer.id, answer.text] as const));
|
||||
const labels = answerIds.map((answerId) => {
|
||||
const label = answerMap.get(answerId);
|
||||
if (!label) {
|
||||
throw new Error(
|
||||
`Matrix poll option id "${answerId}" is not valid for poll ${parsed.question}.`,
|
||||
);
|
||||
}
|
||||
return label;
|
||||
});
|
||||
|
||||
return {
|
||||
answerIds,
|
||||
labels,
|
||||
maxSelections: parsed.maxSelections,
|
||||
};
|
||||
}
|
||||
|
||||
export async function voteMatrixPoll(
|
||||
roomId: string,
|
||||
pollId: string,
|
||||
opts: MatrixActionClientOpts & {
|
||||
optionId?: string;
|
||||
optionIds?: string[];
|
||||
optionIndex?: number;
|
||||
optionIndexes?: number[];
|
||||
} = {},
|
||||
) {
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const pollEvent = await client.getEvent(resolvedRoom, pollId);
|
||||
const eventType = typeof pollEvent.type === "string" ? pollEvent.type : "";
|
||||
if (!isPollStartType(eventType)) {
|
||||
throw new Error(`Event ${pollId} is not a Matrix poll start event.`);
|
||||
}
|
||||
|
||||
const { answerIds, labels, maxSelections } = resolveSelectedAnswerIds({
|
||||
optionIds: [...(opts.optionIds ?? []), ...(opts.optionId ? [opts.optionId] : [])],
|
||||
optionIndexes: [
|
||||
...(opts.optionIndexes ?? []),
|
||||
...(opts.optionIndex !== undefined ? [opts.optionIndex] : []),
|
||||
],
|
||||
pollContent: pollEvent.content as PollStartContent,
|
||||
});
|
||||
|
||||
const content = buildPollResponseContent(pollId, answerIds);
|
||||
const eventId = await client.sendEvent(resolvedRoom, "m.poll.response", content);
|
||||
return {
|
||||
eventId: eventId ?? null,
|
||||
roomId: resolvedRoom,
|
||||
pollId,
|
||||
answerIds,
|
||||
labels,
|
||||
maxSelections,
|
||||
};
|
||||
});
|
||||
}
|
||||
109
extensions/matrix/src/matrix/actions/profile.test.ts
Normal file
109
extensions/matrix/src/matrix/actions/profile.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadWebMediaMock = vi.fn();
|
||||
const syncMatrixOwnProfileMock = vi.fn();
|
||||
const withResolvedActionClientMock = vi.fn();
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
media: {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../profile.js", () => ({
|
||||
syncMatrixOwnProfile: (...args: unknown[]) => syncMatrixOwnProfileMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
|
||||
}));
|
||||
|
||||
const { updateMatrixOwnProfile } = await import("./profile.js");
|
||||
|
||||
describe("matrix profile actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("avatar"),
|
||||
contentType: "image/png",
|
||||
fileName: "avatar.png",
|
||||
});
|
||||
syncMatrixOwnProfileMock.mockResolvedValue({
|
||||
skipped: false,
|
||||
displayNameUpdated: true,
|
||||
avatarUpdated: true,
|
||||
resolvedAvatarUrl: "mxc://example/avatar",
|
||||
convertedAvatarFromHttp: true,
|
||||
uploadedAvatarSource: "http",
|
||||
});
|
||||
});
|
||||
|
||||
it("trims profile fields and persists through the action client wrapper", async () => {
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
});
|
||||
});
|
||||
|
||||
await updateMatrixOwnProfile({
|
||||
accountId: "ops",
|
||||
displayName: " Ops Bot ",
|
||||
avatarUrl: " mxc://example/avatar ",
|
||||
avatarPath: " /tmp/avatar.png ",
|
||||
});
|
||||
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
|
||||
{
|
||||
accountId: "ops",
|
||||
displayName: " Ops Bot ",
|
||||
avatarUrl: " mxc://example/avatar ",
|
||||
avatarPath: " /tmp/avatar.png ",
|
||||
},
|
||||
expect.any(Function),
|
||||
"persist",
|
||||
);
|
||||
expect(syncMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "@bot:example.org",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/avatar",
|
||||
avatarPath: "/tmp/avatar.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("bridges avatar loaders through Matrix runtime media helpers", async () => {
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
});
|
||||
});
|
||||
|
||||
await updateMatrixOwnProfile({
|
||||
avatarUrl: "https://cdn.example.org/avatar.png",
|
||||
avatarPath: "/tmp/avatar.png",
|
||||
});
|
||||
|
||||
const call = syncMatrixOwnProfileMock.mock.calls[0]?.[0] as
|
||||
| {
|
||||
loadAvatarFromUrl: (url: string, maxBytes: number) => Promise<unknown>;
|
||||
loadAvatarFromPath: (path: string, maxBytes: number) => Promise<unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!call) {
|
||||
throw new Error("syncMatrixOwnProfile was not called");
|
||||
}
|
||||
|
||||
await call.loadAvatarFromUrl("https://cdn.example.org/avatar.png", 123);
|
||||
await call.loadAvatarFromPath("/tmp/avatar.png", 456);
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenNthCalledWith(1, "https://cdn.example.org/avatar.png", 123);
|
||||
expect(loadWebMediaMock).toHaveBeenNthCalledWith(2, "/tmp/avatar.png", {
|
||||
maxBytes: 456,
|
||||
localRoots: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
37
extensions/matrix/src/matrix/actions/profile.ts
Normal file
37
extensions/matrix/src/matrix/actions/profile.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js";
|
||||
import { withResolvedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function updateMatrixOwnProfile(
|
||||
opts: MatrixActionClientOpts & {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
avatarPath?: string;
|
||||
} = {},
|
||||
): Promise<MatrixProfileSyncResult> {
|
||||
const displayName = opts.displayName?.trim();
|
||||
const avatarUrl = opts.avatarUrl?.trim();
|
||||
const avatarPath = opts.avatarPath?.trim();
|
||||
const runtime = getMatrixRuntime();
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) => {
|
||||
const userId = await client.getUserId();
|
||||
return await syncMatrixOwnProfile({
|
||||
client,
|
||||
userId,
|
||||
displayName: displayName || undefined,
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
avatarPath: avatarPath || undefined,
|
||||
loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes),
|
||||
loadAvatarFromPath: async (path, maxBytes) =>
|
||||
await runtime.media.loadWebMedia(path, {
|
||||
maxBytes,
|
||||
localRoots: opts.mediaLocalRoots,
|
||||
}),
|
||||
});
|
||||
},
|
||||
"persist",
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { listMatrixReactions, removeMatrixReactions } from "./reactions.js";
|
||||
|
||||
function createReactionsClient(params: {
|
||||
@@ -106,4 +106,30 @@ describe("matrix reaction actions", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import {
|
||||
buildMatrixReactionRelationsPath,
|
||||
selectOwnMatrixReactionEventIds,
|
||||
summarizeMatrixReactionEvents,
|
||||
} from "../reaction-common.js";
|
||||
import { withResolvedRoomAction } from "./client.js";
|
||||
import { resolveMatrixActionLimit } from "./limits.js";
|
||||
import {
|
||||
EventType,
|
||||
RelationType,
|
||||
type MatrixActionClientOpts,
|
||||
type MatrixRawEvent,
|
||||
type MatrixReactionSummary,
|
||||
type ReactionEventContent,
|
||||
} from "./types.js";
|
||||
|
||||
function getReactionsPath(roomId: string, messageId: string): string {
|
||||
return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`;
|
||||
}
|
||||
type ActionClient = NonNullable<MatrixActionClientOpts["client"]>;
|
||||
|
||||
async function listReactionEvents(
|
||||
client: NonNullable<MatrixActionClientOpts["client"]>,
|
||||
async function listMatrixReactionEvents(
|
||||
client: ActionClient,
|
||||
roomId: string,
|
||||
messageId: string,
|
||||
limit: number,
|
||||
): Promise<MatrixRawEvent[]> {
|
||||
const res = (await client.doRequest("GET", getReactionsPath(roomId, messageId), {
|
||||
const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), {
|
||||
dir: "b",
|
||||
limit,
|
||||
})) as { chunk: MatrixRawEvent[] };
|
||||
return res.chunk;
|
||||
})) as { chunk?: MatrixRawEvent[] };
|
||||
return Array.isArray(res.chunk) ? res.chunk : [];
|
||||
}
|
||||
|
||||
export async function listMatrixReactions(
|
||||
@@ -32,36 +31,11 @@ export async function listMatrixReactions(
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { limit?: number } = {},
|
||||
): Promise<MatrixReactionSummary[]> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const limit = resolveMatrixActionLimit(opts.limit, 100);
|
||||
const chunk = await listReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
const summaries = new Map<string, MatrixReactionSummary>();
|
||||
for (const event of chunk) {
|
||||
const content = event.content as ReactionEventContent;
|
||||
const key = content["m.relates_to"]?.key;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const sender = event.sender ?? "";
|
||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||
key,
|
||||
count: 0,
|
||||
users: [],
|
||||
};
|
||||
entry.count += 1;
|
||||
if (sender && !entry.users.includes(sender)) {
|
||||
entry.users.push(sender);
|
||||
}
|
||||
summaries.set(key, entry);
|
||||
}
|
||||
return Array.from(summaries.values());
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit);
|
||||
return summarizeMatrixReactionEvents(chunk);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMatrixReactions(
|
||||
@@ -69,34 +43,17 @@ export async function removeMatrixReactions(
|
||||
messageId: string,
|
||||
opts: MatrixActionClientOpts & { emoji?: string } = {},
|
||||
): Promise<{ removed: number }> {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
const chunk = await listReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200);
|
||||
const userId = await client.getUserId();
|
||||
if (!userId) {
|
||||
return { removed: 0 };
|
||||
}
|
||||
const targetEmoji = opts.emoji?.trim();
|
||||
const toRemove = chunk
|
||||
.filter((event) => event.sender === userId)
|
||||
.filter((event) => {
|
||||
if (!targetEmoji) {
|
||||
return true;
|
||||
}
|
||||
const content = event.content as ReactionEventContent;
|
||||
return content["m.relates_to"]?.key === targetEmoji;
|
||||
})
|
||||
.map((event) => event.event_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
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 };
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
79
extensions/matrix/src/matrix/actions/room.test.ts
Normal file
79
extensions/matrix/src/matrix/actions/room.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { getMatrixMemberInfo, getMatrixRoomInfo } from "./room.js";
|
||||
|
||||
function createRoomClient() {
|
||||
const getRoomStateEvent = vi.fn(async (_roomId: string, eventType: string) => {
|
||||
switch (eventType) {
|
||||
case "m.room.name":
|
||||
return { name: "Ops Room" };
|
||||
case "m.room.topic":
|
||||
return { topic: "Incidents" };
|
||||
case "m.room.canonical_alias":
|
||||
return { alias: "#ops:example.org" };
|
||||
default:
|
||||
throw new Error(`unexpected state event ${eventType}`);
|
||||
}
|
||||
});
|
||||
const getJoinedRoomMembers = vi.fn(async () => [
|
||||
{ user_id: "@alice:example.org" },
|
||||
{ user_id: "@bot:example.org" },
|
||||
]);
|
||||
const getUserProfile = vi.fn(async () => ({
|
||||
displayname: "Alice",
|
||||
avatar_url: "mxc://example.org/alice",
|
||||
}));
|
||||
|
||||
return {
|
||||
client: {
|
||||
getRoomStateEvent,
|
||||
getJoinedRoomMembers,
|
||||
getUserProfile,
|
||||
stop: vi.fn(),
|
||||
} as unknown as MatrixClient,
|
||||
getRoomStateEvent,
|
||||
getJoinedRoomMembers,
|
||||
getUserProfile,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix room actions", () => {
|
||||
it("returns room details from the resolved Matrix room id", async () => {
|
||||
const { client, getJoinedRoomMembers, getRoomStateEvent } = createRoomClient();
|
||||
|
||||
const result = await getMatrixRoomInfo("room:!ops:example.org", { client });
|
||||
|
||||
expect(getRoomStateEvent).toHaveBeenCalledWith("!ops:example.org", "m.room.name", "");
|
||||
expect(getJoinedRoomMembers).toHaveBeenCalledWith("!ops:example.org");
|
||||
expect(result).toEqual({
|
||||
roomId: "!ops:example.org",
|
||||
name: "Ops Room",
|
||||
topic: "Incidents",
|
||||
canonicalAlias: "#ops:example.org",
|
||||
altAliases: [],
|
||||
memberCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves optional room ids when looking up member info", async () => {
|
||||
const { client, getUserProfile } = createRoomClient();
|
||||
|
||||
const result = await getMatrixMemberInfo("@alice:example.org", {
|
||||
client,
|
||||
roomId: "room:!ops:example.org",
|
||||
});
|
||||
|
||||
expect(getUserProfile).toHaveBeenCalledWith("@alice:example.org");
|
||||
expect(result).toEqual({
|
||||
userId: "@alice:example.org",
|
||||
profile: {
|
||||
displayName: "Alice",
|
||||
avatarUrl: "mxc://example.org/alice",
|
||||
},
|
||||
membership: null,
|
||||
powerLevel: null,
|
||||
displayName: "Alice",
|
||||
roomId: "!ops:example.org",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,15 @@
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import { resolveActionClient } from "./client.js";
|
||||
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
|
||||
import { EventType, type MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
export async function getMatrixMemberInfo(
|
||||
userId: string,
|
||||
opts: MatrixActionClientOpts & { roomId?: string } = {},
|
||||
) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
|
||||
// @vector-im/matrix-bot-sdk uses getUserProfile
|
||||
const profile = await client.getUserProfile(userId);
|
||||
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
|
||||
// We'd need to fetch room state separately if needed
|
||||
// Membership and power levels are not included in profile calls; fetch state separately if needed.
|
||||
return {
|
||||
userId,
|
||||
profile: {
|
||||
@@ -24,18 +21,11 @@ export async function getMatrixMemberInfo(
|
||||
displayName: profile?.displayname ?? null,
|
||||
roomId: roomId ?? null,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) {
|
||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||
try {
|
||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||
// @vector-im/matrix-bot-sdk uses getRoomState for state events
|
||||
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
|
||||
let name: string | null = null;
|
||||
let topic: string | null = null;
|
||||
let canonicalAlias: string | null = null;
|
||||
@@ -43,21 +33,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
|
||||
name = nameState?.name ?? null;
|
||||
name = typeof nameState?.name === "string" ? nameState.name : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
|
||||
topic = topicState?.topic ?? null;
|
||||
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
|
||||
canonicalAlias = aliasState?.alias ?? null;
|
||||
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -77,9 +67,5 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
||||
altAliases: [], // Would need separate query
|
||||
memberCount,
|
||||
};
|
||||
} finally {
|
||||
if (stopOnDone) {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
87
extensions/matrix/src/matrix/actions/summary.test.ts
Normal file
87
extensions/matrix/src/matrix/actions/summary.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeMatrixRawEvent } from "./summary.js";
|
||||
|
||||
describe("summarizeMatrixRawEvent", () => {
|
||||
it("replaces bare media filenames with a media marker", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "photo.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
eventId: "$image",
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
});
|
||||
expect(summary.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves captions while marking media summaries", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "can you see this?",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
body: "can you see this?",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "can you see this?",
|
||||
filename: "photo.jpg",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a sentence ending in a file extension as a bare filename", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$image",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body: "see image.png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
body: "see image.png",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "see image.png",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves text messages unchanged", () => {
|
||||
const summary = summarizeMatrixRawEvent({
|
||||
event_id: "$text",
|
||||
sender: "@gum:matrix.example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: 123,
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.body).toBe("hello");
|
||||
expect(summary.attachment).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { resolveMatrixMessageAttachment, resolveMatrixMessageBody } from "../media-text.js";
|
||||
import { fetchMatrixPollMessageSummary } from "../poll-summary.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
@@ -30,8 +32,17 @@ export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSum
|
||||
return {
|
||||
eventId: event.event_id,
|
||||
sender: event.sender,
|
||||
body: content.body,
|
||||
body: resolveMatrixMessageBody({
|
||||
body: content.body,
|
||||
filename: content.filename,
|
||||
msgtype: content.msgtype,
|
||||
}),
|
||||
msgtype: content.msgtype,
|
||||
attachment: resolveMatrixMessageAttachment({
|
||||
body: content.body,
|
||||
filename: content.filename,
|
||||
msgtype: content.msgtype,
|
||||
}),
|
||||
timestamp: event.origin_server_ts,
|
||||
relatesTo,
|
||||
};
|
||||
@@ -67,6 +78,10 @@ export async function fetchEventSummary(
|
||||
if (raw.unsigned?.redacted_because) {
|
||||
return null;
|
||||
}
|
||||
const pollSummary = await fetchMatrixPollMessageSummary(client, roomId, raw);
|
||||
if (pollSummary) {
|
||||
return pollSummary;
|
||||
}
|
||||
return summarizeMatrixRawEvent(raw);
|
||||
} catch {
|
||||
// Event not found, redacted, or inaccessible - return null
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
MATRIX_REACTION_EVENT_TYPE,
|
||||
type MatrixReactionEventContent,
|
||||
} from "../reaction-common.js";
|
||||
import type { MatrixClient, MessageEventContent } from "../sdk.js";
|
||||
export type { MatrixRawEvent } from "../sdk.js";
|
||||
export type { MatrixReactionSummary } from "../reaction-common.js";
|
||||
|
||||
export const MsgType = {
|
||||
Text: "m.text",
|
||||
@@ -6,17 +14,17 @@ export const MsgType = {
|
||||
|
||||
export const RelationType = {
|
||||
Replace: "m.replace",
|
||||
Annotation: "m.annotation",
|
||||
Annotation: MATRIX_ANNOTATION_RELATION_TYPE,
|
||||
} as const;
|
||||
|
||||
export const EventType = {
|
||||
RoomMessage: "m.room.message",
|
||||
RoomPinnedEvents: "m.room.pinned_events",
|
||||
RoomTopic: "m.room.topic",
|
||||
Reaction: "m.reaction",
|
||||
Reaction: MATRIX_REACTION_EVENT_TYPE,
|
||||
} as const;
|
||||
|
||||
export type RoomMessageEventContent = {
|
||||
export type RoomMessageEventContent = MessageEventContent & {
|
||||
msgtype: string;
|
||||
body: string;
|
||||
"m.new_content"?: RoomMessageEventContent;
|
||||
@@ -27,13 +35,7 @@ export type RoomMessageEventContent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ReactionEventContent = {
|
||||
"m.relates_to": {
|
||||
rel_type: string;
|
||||
event_id: string;
|
||||
key: string;
|
||||
};
|
||||
};
|
||||
export type ReactionEventContent = MatrixReactionEventContent;
|
||||
|
||||
export type RoomPinnedEventsEventContent = {
|
||||
pinned: string[];
|
||||
@@ -43,21 +45,13 @@ export type RoomTopicEventContent = {
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
export type MatrixRawEvent = {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
type: string;
|
||||
origin_server_ts: number;
|
||||
content: Record<string, unknown>;
|
||||
unsigned?: {
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixActionClientOpts = {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: "none" | "prepared" | "started";
|
||||
};
|
||||
|
||||
export type MatrixMessageSummary = {
|
||||
@@ -65,6 +59,7 @@ export type MatrixMessageSummary = {
|
||||
sender?: string;
|
||||
body?: string;
|
||||
msgtype?: string;
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
timestamp?: number;
|
||||
relatesTo?: {
|
||||
relType?: string;
|
||||
@@ -73,10 +68,12 @@ export type MatrixMessageSummary = {
|
||||
};
|
||||
};
|
||||
|
||||
export type MatrixReactionSummary = {
|
||||
key: string;
|
||||
count: number;
|
||||
users: string[];
|
||||
export type MatrixMessageAttachmentKind = "audio" | "file" | "image" | "sticker" | "video";
|
||||
|
||||
export type MatrixMessageAttachmentSummary = {
|
||||
kind: MatrixMessageAttachmentKind;
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type MatrixActionClient = {
|
||||
|
||||
101
extensions/matrix/src/matrix/actions/verification.test.ts
Normal file
101
extensions/matrix/src/matrix/actions/verification.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const withStartedActionClientMock = vi.fn();
|
||||
const loadConfigMock = vi.fn(() => ({
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => ({
|
||||
config: {
|
||||
loadConfig: loadConfigMock,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
|
||||
}));
|
||||
|
||||
const { listMatrixVerifications } = await import("./verification.js");
|
||||
|
||||
describe("matrix verification actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("points encryption guidance at the selected Matrix account", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto: null });
|
||||
});
|
||||
|
||||
await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow(
|
||||
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the resolved default Matrix account when accountId is omitted", async () => {
|
||||
loadConfigMock.mockReturnValue({
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
accounts: {
|
||||
ops: {
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto: null });
|
||||
});
|
||||
|
||||
await expect(listMatrixVerifications()).rejects.toThrow(
|
||||
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses explicit cfg instead of runtime config when crypto is unavailable", async () => {
|
||||
const explicitCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
loadConfigMock.mockImplementation(() => {
|
||||
throw new Error("verification actions should not reload runtime config when cfg is provided");
|
||||
});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ crypto: null });
|
||||
});
|
||||
|
||||
await expect(listMatrixVerifications({ cfg: explicitCfg, accountId: "ops" })).rejects.toThrow(
|
||||
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
|
||||
);
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
236
extensions/matrix/src/matrix/actions/verification.ts
Normal file
236
extensions/matrix/src/matrix/actions/verification.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
|
||||
import { withStartedActionClient } from "./client.js";
|
||||
import type { MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
function requireCrypto(
|
||||
client: import("../sdk.js").MatrixClient,
|
||||
opts: MatrixActionClientOpts,
|
||||
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
|
||||
if (!client.crypto) {
|
||||
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
|
||||
}
|
||||
return client.crypto;
|
||||
}
|
||||
|
||||
function resolveVerificationId(input: string): string {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) {
|
||||
throw new Error("Matrix verification request id is required");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.listVerifications();
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestMatrixVerification(
|
||||
params: MatrixActionClientOpts & {
|
||||
ownUser?: boolean;
|
||||
userId?: string;
|
||||
deviceId?: string;
|
||||
roomId?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(params, async (client) => {
|
||||
const crypto = requireCrypto(client, params);
|
||||
const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId);
|
||||
return await crypto.requestVerification({
|
||||
ownUser,
|
||||
userId: params.userId?.trim() || undefined,
|
||||
deviceId: params.deviceId?.trim() || undefined,
|
||||
roomId: params.roomId?.trim() || undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.acceptVerification(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { reason?: string; code?: string } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.cancelVerification(resolveVerificationId(requestId), {
|
||||
reason: opts.reason?.trim() || undefined,
|
||||
code: opts.code?.trim() || undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMatrixVerification(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts & { method?: "sas" } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas");
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateMatrixVerificationQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.generateVerificationQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function scanMatrixVerificationQr(
|
||||
requestId: string,
|
||||
qrDataBase64: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
const payload = qrDataBase64.trim();
|
||||
if (!payload) {
|
||||
throw new Error("Matrix QR data is required");
|
||||
}
|
||||
return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.getVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.confirmVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function mismatchMatrixVerificationSas(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.mismatchVerificationSas(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmMatrixVerificationReciprocateQr(
|
||||
requestId: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixEncryptionStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const crypto = requireCrypto(client, opts);
|
||||
const recoveryKey = await crypto.getRecoveryKey();
|
||||
return {
|
||||
encryptionEnabled: true,
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}),
|
||||
pendingVerifications: (await crypto.listVerifications()).length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixVerificationStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withStartedActionClient(opts, async (client) => {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
const payload = {
|
||||
...status,
|
||||
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
|
||||
};
|
||||
if (!opts.includeRecoveryKey) {
|
||||
return payload;
|
||||
}
|
||||
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
|
||||
return {
|
||||
...payload,
|
||||
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) => await client.getRoomKeyBackupStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyMatrixRecoveryKey(
|
||||
recoveryKey: string,
|
||||
opts: MatrixActionClientOpts = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) => await client.verifyWithRecoveryKey(recoveryKey),
|
||||
);
|
||||
}
|
||||
|
||||
export async function restoreMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.restoreRoomKeyBackup({
|
||||
recoveryKey: opts.recoveryKey?.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup());
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
opts: MatrixActionClientOpts & {
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.bootstrapOwnDeviceVerification({
|
||||
recoveryKey: opts.recoveryKey?.trim() || undefined,
|
||||
forceResetCrossSigning: opts.forceResetCrossSigning === true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,26 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
// Support multiple active clients for multi-account
|
||||
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 = normalizeAccountId(accountId);
|
||||
if (client) {
|
||||
activeClients.set(key, client);
|
||||
} else {
|
||||
const key = resolveAccountKey(accountId);
|
||||
if (!client) {
|
||||
activeClients.delete(key);
|
||||
return;
|
||||
}
|
||||
activeClients.set(key, client);
|
||||
}
|
||||
|
||||
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
|
||||
const key = normalizeAccountId(accountId);
|
||||
const key = resolveAccountKey(accountId);
|
||||
return activeClients.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function getAnyActiveMatrixClient(): MatrixClient | null {
|
||||
// Return any available client (for backward compatibility)
|
||||
const first = activeClients.values().next();
|
||||
return first.done ? null : first.value;
|
||||
}
|
||||
|
||||
export function clearAllActiveMatrixClients(): void {
|
||||
activeClients.clear();
|
||||
}
|
||||
|
||||
115
extensions/matrix/src/matrix/backup-health.ts
Normal file
115
extensions/matrix/src/matrix/backup-health.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export type MatrixRoomKeyBackupStatusLike = {
|
||||
serverVersion: string | null;
|
||||
activeVersion: string | null;
|
||||
trusted: boolean | null;
|
||||
matchesDecryptionKey: boolean | null;
|
||||
decryptionKeyCached: boolean | null;
|
||||
keyLoadAttempted: boolean;
|
||||
keyLoadError: string | null;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupIssueCode =
|
||||
| "missing-server-backup"
|
||||
| "key-load-failed"
|
||||
| "key-not-loaded"
|
||||
| "key-mismatch"
|
||||
| "untrusted-signature"
|
||||
| "inactive"
|
||||
| "indeterminate"
|
||||
| "ok";
|
||||
|
||||
export type MatrixRoomKeyBackupIssue = {
|
||||
code: MatrixRoomKeyBackupIssueCode;
|
||||
summary: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export function resolveMatrixRoomKeyBackupIssue(
|
||||
backup: MatrixRoomKeyBackupStatusLike,
|
||||
): MatrixRoomKeyBackupIssue {
|
||||
if (!backup.serverVersion) {
|
||||
return {
|
||||
code: "missing-server-backup",
|
||||
summary: "missing on server",
|
||||
message: "no room-key backup exists on the homeserver",
|
||||
};
|
||||
}
|
||||
if (backup.decryptionKeyCached === false) {
|
||||
if (backup.keyLoadError) {
|
||||
return {
|
||||
code: "key-load-failed",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`,
|
||||
};
|
||||
}
|
||||
if (backup.keyLoadAttempted) {
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message:
|
||||
"backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "key-not-loaded",
|
||||
summary: "present but backup key unavailable on this device",
|
||||
message: "backup decryption key is not loaded on this device",
|
||||
};
|
||||
}
|
||||
if (backup.matchesDecryptionKey === false) {
|
||||
return {
|
||||
code: "key-mismatch",
|
||||
summary: "present but backup key mismatch on this device",
|
||||
message: "backup key mismatch (this device does not have the matching backup decryption key)",
|
||||
};
|
||||
}
|
||||
if (backup.trusted === false) {
|
||||
return {
|
||||
code: "untrusted-signature",
|
||||
summary: "present but not trusted on this device",
|
||||
message: "backup signature chain is not trusted by this device",
|
||||
};
|
||||
}
|
||||
if (!backup.activeVersion) {
|
||||
return {
|
||||
code: "inactive",
|
||||
summary: "present on server but inactive on this device",
|
||||
message: "backup exists but is not active on this device",
|
||||
};
|
||||
}
|
||||
if (
|
||||
backup.trusted === null ||
|
||||
backup.matchesDecryptionKey === null ||
|
||||
backup.decryptionKeyCached === null
|
||||
) {
|
||||
return {
|
||||
code: "indeterminate",
|
||||
summary: "present but trust state unknown",
|
||||
message: "backup trust state could not be fully determined",
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "ok",
|
||||
summary: "active and trusted on this device",
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixRoomKeyBackupReadinessError(
|
||||
backup: MatrixRoomKeyBackupStatusLike,
|
||||
opts: {
|
||||
requireServerBackup: boolean;
|
||||
},
|
||||
): string | null {
|
||||
const issue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
if (issue.code === "missing-server-backup") {
|
||||
return opts.requireServerBackup ? "Matrix room key backup is missing on the homeserver." : null;
|
||||
}
|
||||
if (issue.code === "ok") {
|
||||
return null;
|
||||
}
|
||||
if (issue.message) {
|
||||
return `Matrix room key backup is not usable: ${issue.message}.`;
|
||||
}
|
||||
return "Matrix room key backup is not usable on this device.";
|
||||
}
|
||||
79
extensions/matrix/src/matrix/client-bootstrap.test.ts
Normal file
79
extensions/matrix/src/matrix/client-bootstrap.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockMatrixClient,
|
||||
matrixClientResolverMocks,
|
||||
primeMatrixClientResolverMocks,
|
||||
} from "./client-resolver.test-helpers.js";
|
||||
|
||||
const {
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
vi.mock("./active-client.js", () => ({
|
||||
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
acquireSharedMatrixClient: (...args: unknown[]) => acquireSharedMatrixClientMock(...args),
|
||||
isBunRuntime: () => isBunRuntimeMock(),
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client/shared.js", () => ({
|
||||
releaseSharedClientInstance: (...args: unknown[]) => releaseSharedClientInstanceMock(...args),
|
||||
}));
|
||||
|
||||
const { resolveRuntimeMatrixClientWithReadiness, withResolvedRuntimeMatrixClient } =
|
||||
await import("./client-bootstrap.js");
|
||||
|
||||
describe("client bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
primeMatrixClientResolverMocks({ resolved: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("releases leased shared clients when readiness setup fails", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
vi.mocked(sharedClient.prepareForOneOff).mockRejectedValue(new Error("prepare failed"));
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
resolveRuntimeMatrixClientWithReadiness({
|
||||
accountId: "default",
|
||||
readiness: "prepared",
|
||||
}),
|
||||
).rejects.toThrow("prepare failed");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("releases leased shared clients when the wrapped action throws during readiness", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
vi.mocked(sharedClient.start).mockRejectedValue(new Error("start failed"));
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
await expect(
|
||||
withResolvedRuntimeMatrixClient(
|
||||
{
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
},
|
||||
async () => "ok",
|
||||
),
|
||||
).rejects.toThrow("start failed");
|
||||
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,144 @@
|
||||
import { createMatrixClient } from "./client/create-client.js";
|
||||
import { startMatrixClientWithGrace } from "./client/startup.js";
|
||||
import { getMatrixLogService } from "./sdk-runtime.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import { acquireSharedMatrixClient, isBunRuntime, resolveMatrixAuthContext } from "./client.js";
|
||||
import { releaseSharedClientInstance } from "./client/shared.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
type MatrixClientBootstrapAuth = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
encryption?: boolean;
|
||||
type ResolvedRuntimeMatrixClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
cleanup?: (mode: ResolvedRuntimeMatrixClientStopMode) => Promise<void>;
|
||||
};
|
||||
|
||||
type MatrixCryptoPrepare = {
|
||||
prepare: (rooms?: string[]) => Promise<void>;
|
||||
};
|
||||
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
|
||||
|
||||
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
|
||||
type MatrixResolvedClientHook = (
|
||||
client: MatrixClient,
|
||||
context: { preparedByDefault: boolean },
|
||||
) => Promise<void> | void;
|
||||
|
||||
export async function createPreparedMatrixClient(opts: {
|
||||
auth: MatrixClientBootstrapAuth;
|
||||
async function ensureResolvedClientReadiness(params: {
|
||||
client: MatrixClient;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
preparedByDefault: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.readiness === "started") {
|
||||
await params.client.start();
|
||||
return;
|
||||
}
|
||||
if (params.readiness === "prepared" || (!params.readiness && params.preparedByDefault)) {
|
||||
await params.client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMatrixNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
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?: string | null;
|
||||
onResolved?: MatrixResolvedClientHook;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
ensureMatrixNodeRuntime();
|
||||
if (opts.client) {
|
||||
await opts.onResolved?.(opts.client, { preparedByDefault: false });
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
|
||||
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const authContext = resolveMatrixAuthContext({
|
||||
cfg,
|
||||
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.
|
||||
}
|
||||
const active = getActiveMatrixClient(authContext.accountId);
|
||||
if (active) {
|
||||
await opts.onResolved?.(active, { preparedByDefault: false });
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
await startMatrixClientWithGrace({
|
||||
|
||||
const client = await acquireSharedMatrixClient({
|
||||
cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: authContext.accountId,
|
||||
startClient: false,
|
||||
});
|
||||
try {
|
||||
await opts.onResolved?.(client, { preparedByDefault: true });
|
||||
} catch (err) {
|
||||
await releaseSharedClientInstance(client, "stop");
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
client,
|
||||
onError: (err: unknown) => {
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.error("MatrixClientBootstrap", "client.start() error:", err);
|
||||
stopOnDone: true,
|
||||
cleanup: async (mode) => {
|
||||
await releaseSharedClientInstance(client, mode);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClientWithReadiness(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureResolvedClientReadiness({
|
||||
client,
|
||||
readiness: opts.readiness,
|
||||
preparedByDefault: context.preparedByDefault,
|
||||
});
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function stopResolvedRuntimeMatrixClient(
|
||||
resolved: ResolvedRuntimeMatrixClient,
|
||||
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (resolved.cleanup) {
|
||||
await resolved.cleanup(mode);
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedRuntimeMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
|
||||
}
|
||||
}
|
||||
|
||||
94
extensions/matrix/src/matrix/client-resolver.test-helpers.ts
Normal file
94
extensions/matrix/src/matrix/client-resolver.test-helpers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
type MatrixClientResolverMocks = {
|
||||
loadConfigMock: Mock<() => unknown>;
|
||||
getMatrixRuntimeMock: Mock<() => unknown>;
|
||||
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
|
||||
acquireSharedMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
|
||||
releaseSharedClientInstanceMock: Mock<(...args: unknown[]) => Promise<boolean>>;
|
||||
isBunRuntimeMock: Mock<() => boolean>;
|
||||
resolveMatrixAuthContextMock: Mock<
|
||||
(params: { cfg: unknown; accountId?: string | null }) => unknown
|
||||
>;
|
||||
};
|
||||
|
||||
export const matrixClientResolverMocks: MatrixClientResolverMocks = {
|
||||
loadConfigMock: vi.fn(() => ({})),
|
||||
getMatrixRuntimeMock: vi.fn(),
|
||||
getActiveMatrixClientMock: vi.fn(),
|
||||
acquireSharedMatrixClientMock: vi.fn(),
|
||||
releaseSharedClientInstanceMock: vi.fn(),
|
||||
isBunRuntimeMock: vi.fn(() => false),
|
||||
resolveMatrixAuthContextMock: vi.fn(),
|
||||
};
|
||||
|
||||
export function createMockMatrixClient(): MatrixClient {
|
||||
return {
|
||||
prepareForOneOff: vi.fn(async () => undefined),
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
stopAndPersist: vi.fn(async () => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
export function primeMatrixClientResolverMocks(params?: {
|
||||
cfg?: unknown;
|
||||
accountId?: string;
|
||||
resolved?: Record<string, unknown>;
|
||||
auth?: Record<string, unknown>;
|
||||
client?: MatrixClient;
|
||||
}): MatrixClient {
|
||||
const {
|
||||
loadConfigMock,
|
||||
getMatrixRuntimeMock,
|
||||
getActiveMatrixClientMock,
|
||||
acquireSharedMatrixClientMock,
|
||||
releaseSharedClientInstanceMock,
|
||||
isBunRuntimeMock,
|
||||
resolveMatrixAuthContextMock,
|
||||
} = matrixClientResolverMocks;
|
||||
|
||||
const cfg = params?.cfg ?? {};
|
||||
const accountId = params?.accountId ?? "default";
|
||||
const defaultResolved = {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
password: undefined,
|
||||
deviceId: "DEVICE123",
|
||||
encryption: false,
|
||||
};
|
||||
const client = params?.client ?? createMockMatrixClient();
|
||||
|
||||
vi.clearAllMocks();
|
||||
loadConfigMock.mockReturnValue(cfg);
|
||||
getMatrixRuntimeMock.mockReturnValue({
|
||||
config: {
|
||||
loadConfig: loadConfigMock,
|
||||
},
|
||||
});
|
||||
getActiveMatrixClientMock.mockReturnValue(null);
|
||||
isBunRuntimeMock.mockReturnValue(false);
|
||||
releaseSharedClientInstanceMock.mockReset().mockResolvedValue(true);
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({
|
||||
cfg: explicitCfg,
|
||||
accountId: explicitAccountId,
|
||||
}: {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
}) => ({
|
||||
cfg: explicitCfg,
|
||||
env: process.env,
|
||||
accountId: explicitAccountId ?? accountId,
|
||||
resolved: {
|
||||
...defaultResolved,
|
||||
...params?.resolved,
|
||||
},
|
||||
}),
|
||||
);
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(client);
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixConfig } from "./client.js";
|
||||
import {
|
||||
getMatrixScopedEnvVarNames,
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.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", () => {
|
||||
@@ -29,6 +48,7 @@ describe("resolveMatrixConfig", () => {
|
||||
userId: "@cfg:example.org",
|
||||
accessToken: "cfg-token",
|
||||
password: "cfg-pass",
|
||||
deviceId: undefined,
|
||||
deviceName: "CfgDevice",
|
||||
initialSyncLimit: 5,
|
||||
encryption: false,
|
||||
@@ -42,6 +62,7 @@ describe("resolveMatrixConfig", () => {
|
||||
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);
|
||||
@@ -49,8 +70,618 @@ describe("resolveMatrixConfig", () => {
|
||||
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: {
|
||||
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");
|
||||
});
|
||||
|
||||
it("uses collision-free scoped env var names for normalized account ids", () => {
|
||||
expect(getMatrixScopedEnvVarNames("ops-prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X2D_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
expect(getMatrixScopedEnvVarNames("ops_prod").accessToken).toBe(
|
||||
"MATRIX_OPS_X5F_PROD_ACCESS_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers channels.matrix.accounts.default over global env for the default account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass", // pragma: allowlist secret
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_HOMESERVER: "https://env.example.org",
|
||||
MATRIX_USER_ID: "@env:example.org",
|
||||
MATRIX_PASSWORD: "env-pass",
|
||||
MATRIX_DEVICE_NAME: "EnvDevice",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixAuthContext({ cfg, env });
|
||||
expect(resolved.accountId).toBe("default");
|
||||
expect(resolved.resolved).toMatchObject({
|
||||
homeserver: "https://matrix.gumadeiras.com",
|
||||
userId: "@pinguini:matrix.gumadeiras.com",
|
||||
password: "cfg-pass",
|
||||
deviceName: "OpenClaw Gateway Pinguini",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores typoed defaultAccount values that do not map to a real Matrix account", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("default");
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull();
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(() =>
|
||||
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "typo" }),
|
||||
).toThrow(/Matrix account "typo" is not configured/i);
|
||||
});
|
||||
|
||||
it("allows explicit non-default account ids backed only by scoped env vars", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://legacy.example.org",
|
||||
accessToken: "legacy-token",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_OPS_HOMESERVER: "https://ops.example.org",
|
||||
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
expect(resolveMatrixAuthContext({ cfg, env, accountId: "ops" }).accountId).toBe("ops");
|
||||
});
|
||||
|
||||
it("does not inherit the base deviceId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit the base userId for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
userId: "@base:example.org",
|
||||
accessToken: "base-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", {} as NodeJS.ProcessEnv);
|
||||
expect(resolved.userId).toBe("");
|
||||
});
|
||||
|
||||
it("does not inherit base or global auth secrets for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
accessToken: "base-token",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
deviceId: "BASEDEVICE",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_ACCESS_TOKEN: "global-token",
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
MATRIX_DEVICE_ID: "GLOBALDEVICE",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.accessToken).toBeUndefined();
|
||||
expect(resolved.password).toBe("ops-pass");
|
||||
expect(resolved.deviceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit a base password for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://base.example.org",
|
||||
password: "base-pass", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const env = {
|
||||
MATRIX_PASSWORD: "global-pass",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, "ops", env);
|
||||
expect(resolved.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
expect(validateMatrixHomeserverUrl("http://127.0.0.1:8008")).toBe("http://127.0.0.1:8008");
|
||||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist 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({
|
||||
accountId: "default",
|
||||
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),
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
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: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist 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: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "cached-token",
|
||||
deviceId: "CACHEDDEVICE",
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects embedded credentials in Matrix homeserver URLs", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://user:pass@matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
|
||||
"Matrix homeserver URL must not include embedded credentials",
|
||||
);
|
||||
});
|
||||
|
||||
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: {
|
||||
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(auth.accountId).toBe("default");
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
}),
|
||||
expect.any(Object),
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves token-only non-default account userId from whoami instead of inheriting the base user", async () => {
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
user_id: "@ops:example.org",
|
||||
device_id: "OPSDEVICE",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
userId: "@base:example.org",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
|
||||
expect(auth.userId).toBe("@ops:example.org");
|
||||
expect(auth.deviceId).toBe("OPSDEVICE");
|
||||
});
|
||||
|
||||
it("uses named-account password auth instead of inheriting the base access token", async () => {
|
||||
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue(null);
|
||||
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(false);
|
||||
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
|
||||
access_token: "ops-token",
|
||||
user_id: "@ops:example.org",
|
||||
device_id: "OPSDEVICE",
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "legacy-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
password: "ops-pass", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
accountId: "ops",
|
||||
});
|
||||
|
||||
expect(doRequestSpy).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/_matrix/client/v3/login",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: "@ops:example.org" },
|
||||
password: "ops-pass",
|
||||
}),
|
||||
);
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "ops",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
});
|
||||
});
|
||||
|
||||
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: {
|
||||
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({
|
||||
accountId: "default",
|
||||
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: {
|
||||
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({
|
||||
accountId: "default",
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
deviceId: "DEVICE123",
|
||||
encryption: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the sole configured account when no global homeserver is set", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
|
||||
|
||||
expect(auth).toMatchObject({
|
||||
accountId: "ops",
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
encryption: true,
|
||||
});
|
||||
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
homeserver: "https://ops.example.org",
|
||||
userId: "@ops:example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceId: "OPSDEVICE",
|
||||
}),
|
||||
expect.any(Object),
|
||||
"ops",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export type { MatrixAuth } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export { getMatrixScopedEnvVarNames } from "../env-vars.js";
|
||||
export {
|
||||
resolveMatrixConfig,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixEnvAuthReadiness,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
acquireSharedMatrixClient,
|
||||
removeSharedClientInstance,
|
||||
releaseSharedClientInstance,
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
stopSharedClientInstance,
|
||||
} from "./client/shared.js";
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { fetchWithSsrFGuard } from "../../../runtime-api.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
isPrivateOrLoopbackHost,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "../../secret-input.js";
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../account-selection.js";
|
||||
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
|
||||
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
resolveMatrixBaseConfig,
|
||||
listNormalizedMatrixAccountIds,
|
||||
} from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-update.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
|
||||
@@ -14,90 +27,308 @@ function clean(value: unknown, path: string): string {
|
||||
return normalizeResolvedSecretInputString({ value, path }) ?? "";
|
||||
}
|
||||
|
||||
/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
|
||||
function deepMergeConfig<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
|
||||
const merged = { ...base, ...override } as Record<string, unknown>;
|
||||
// Merge known nested objects (dm, actions) so partial overrides keep base fields
|
||||
for (const key of ["dm", "actions"] as const) {
|
||||
const b = base[key];
|
||||
const o = override[key];
|
||||
if (typeof b === "object" && b !== null && typeof o === "object" && o !== null) {
|
||||
merged[key] = { ...(b as Record<string, unknown>), ...(o as Record<string, unknown>) };
|
||||
}
|
||||
}
|
||||
return merged as T;
|
||||
type MatrixEnvConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
};
|
||||
|
||||
type MatrixConfigStringField =
|
||||
| "homeserver"
|
||||
| "userId"
|
||||
| "accessToken"
|
||||
| "password"
|
||||
| "deviceId"
|
||||
| "deviceName";
|
||||
|
||||
function resolveMatrixBaseConfigFieldPath(field: MatrixConfigStringField): string {
|
||||
return `channels.matrix.${field}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Matrix config for a specific account, with fallback to top-level config.
|
||||
* This supports both multi-account (channels.matrix.accounts.*) and
|
||||
* single-account (channels.matrix.*) configurations.
|
||||
*/
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
accountId?: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const matrixBase = cfg.channels?.matrix ?? {};
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
function readMatrixBaseConfigField(
|
||||
matrix: ReturnType<typeof resolveMatrixBaseConfig>,
|
||||
field: MatrixConfigStringField,
|
||||
): string {
|
||||
return clean(matrix[field], resolveMatrixBaseConfigFieldPath(field));
|
||||
}
|
||||
|
||||
// Try to get account-specific config first (direct lookup, then case-insensitive fallback)
|
||||
let accountConfig = accounts?.[normalizedAccountId];
|
||||
if (!accountConfig && accounts) {
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (normalizeAccountId(key) === normalizedAccountId) {
|
||||
accountConfig = accounts[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
function readMatrixAccountConfigField(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
account: Partial<Record<MatrixConfigStringField, unknown>>,
|
||||
field: MatrixConfigStringField,
|
||||
): string {
|
||||
return clean(account[field], resolveMatrixConfigFieldPath(cfg, accountId, field));
|
||||
}
|
||||
|
||||
function clampMatrixInitialSyncLimit(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? Math.max(0, Math.floor(value)) : undefined;
|
||||
}
|
||||
|
||||
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
|
||||
return {
|
||||
homeserver: clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER"),
|
||||
userId: clean(env.MATRIX_USER_ID, "MATRIX_USER_ID"),
|
||||
accessToken: clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") || undefined,
|
||||
password: clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") || undefined,
|
||||
deviceId: clean(env.MATRIX_DEVICE_ID, "MATRIX_DEVICE_ID") || undefined,
|
||||
deviceName: clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export { getMatrixScopedEnvVarNames } from "../../env-vars.js";
|
||||
|
||||
export function resolveMatrixEnvAuthReadiness(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): {
|
||||
ready: boolean;
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
sourceHint: string;
|
||||
missingMessage: string;
|
||||
} {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const scoped = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const scopedReady = hasReadyMatrixEnvAuth(scoped);
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
const keys = getMatrixScopedEnvVarNames(normalizedAccountId);
|
||||
return {
|
||||
ready: scopedReady,
|
||||
homeserver: scoped.homeserver || undefined,
|
||||
userId: scoped.userId || undefined,
|
||||
sourceHint: `${keys.homeserver} (+ auth vars)`,
|
||||
missingMessage: `Set per-account env vars for "${normalizedAccountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`,
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge: account-specific values override top-level values, preserving
|
||||
// nested object inheritance (dm, actions, groups) so partial overrides work.
|
||||
const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
|
||||
const defaultScoped = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env);
|
||||
const global = resolveGlobalMatrixEnvConfig(env);
|
||||
const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScoped);
|
||||
const globalReady = hasReadyMatrixEnvAuth(global);
|
||||
const defaultKeys = getMatrixScopedEnvVarNames(DEFAULT_ACCOUNT_ID);
|
||||
return {
|
||||
ready: defaultScopedReady || globalReady,
|
||||
homeserver: defaultScoped.homeserver || global.homeserver || undefined,
|
||||
userId: defaultScoped.userId || global.userId || undefined,
|
||||
sourceHint: "MATRIX_* or MATRIX_DEFAULT_*",
|
||||
missingMessage:
|
||||
`Set Matrix env vars for the default account ` +
|
||||
`(for example MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN, MATRIX_USER_ID + MATRIX_PASSWORD, ` +
|
||||
`or ${defaultKeys.homeserver} + ${defaultKeys.accessToken}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const homeserver =
|
||||
clean(matrix.homeserver, "channels.matrix.homeserver") ||
|
||||
clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER");
|
||||
const userId =
|
||||
clean(matrix.userId, "channels.matrix.userId") || clean(env.MATRIX_USER_ID, "MATRIX_USER_ID");
|
||||
const accessToken =
|
||||
clean(matrix.accessToken, "channels.matrix.accessToken") ||
|
||||
clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") ||
|
||||
undefined;
|
||||
const password =
|
||||
clean(matrix.password, "channels.matrix.password") ||
|
||||
clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") ||
|
||||
undefined;
|
||||
const deviceName =
|
||||
clean(matrix.deviceName, "channels.matrix.deviceName") ||
|
||||
clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") ||
|
||||
undefined;
|
||||
const initialSyncLimit =
|
||||
typeof matrix.initialSyncLimit === "number"
|
||||
? Math.max(0, Math.floor(matrix.initialSyncLimit))
|
||||
: undefined;
|
||||
export function resolveScopedMatrixEnvConfig(
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixEnvConfig {
|
||||
const keys = getMatrixScopedEnvVarNames(accountId);
|
||||
return {
|
||||
homeserver: clean(env[keys.homeserver], keys.homeserver),
|
||||
userId: clean(env[keys.userId], keys.userId),
|
||||
accessToken: clean(env[keys.accessToken], keys.accessToken) || undefined,
|
||||
password: clean(env[keys.password], keys.password) || undefined,
|
||||
deviceId: clean(env[keys.deviceId], keys.deviceId) || undefined,
|
||||
deviceName: clean(env[keys.deviceName], keys.deviceName) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function hasScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): boolean {
|
||||
const scoped = resolveScopedMatrixEnvConfig(accountId, env);
|
||||
return Boolean(
|
||||
scoped.homeserver ||
|
||||
scoped.userId ||
|
||||
scoped.accessToken ||
|
||||
scoped.password ||
|
||||
scoped.deviceId ||
|
||||
scoped.deviceName,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasReadyMatrixEnvAuth(config: {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accessToken?: string;
|
||||
password?: string;
|
||||
}): boolean {
|
||||
const homeserver = clean(config.homeserver, "matrix.env.homeserver");
|
||||
const userId = clean(config.userId, "matrix.env.userId");
|
||||
const accessToken = clean(config.accessToken, "matrix.env.accessToken");
|
||||
const password = clean(config.password, "matrix.env.password");
|
||||
return Boolean(homeserver && (accessToken || (userId && password)));
|
||||
}
|
||||
|
||||
export function validateMatrixHomeserverUrl(homeserver: string): string {
|
||||
const trimmed = clean(homeserver, "matrix.homeserver");
|
||||
if (!trimmed) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error("Matrix homeserver must be a valid http(s) URL");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new Error("Matrix homeserver must use http:// or https://");
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
throw new Error("Matrix homeserver must include a hostname");
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new Error("Matrix homeserver URL must not include embedded credentials");
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error("Matrix homeserver URL must not include query strings or fragments");
|
||||
}
|
||||
if (parsed.protocol === "http:" && !isPrivateOrLoopbackHost(parsed.hostname)) {
|
||||
throw new Error(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
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 resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
scopedEnv: defaultScopedEnv,
|
||||
channel: {
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver"),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId"),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken"),
|
||||
password: readMatrixBaseConfigField(matrix, "password"),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId"),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName"),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
const initialSyncLimit = clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption = matrix.encryption ?? false;
|
||||
return {
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken,
|
||||
password,
|
||||
deviceName,
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
accessToken: resolvedStrings.accessToken || undefined,
|
||||
password: resolvedStrings.password || undefined,
|
||||
deviceId: resolvedStrings.deviceId || undefined,
|
||||
deviceName: resolvedStrings.deviceName || undefined,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-account function for backward compatibility - resolves default account config.
|
||||
*/
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
export function resolveMatrixConfigForAccount(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env);
|
||||
const matrix = resolveMatrixBaseConfig(cfg);
|
||||
const account = findMatrixAccountConfig(cfg, accountId) ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env);
|
||||
const globalEnv = resolveGlobalMatrixEnvConfig(env);
|
||||
const accountField = (field: MatrixConfigStringField) =>
|
||||
readMatrixAccountConfigField(cfg, normalizedAccountId, account, field);
|
||||
const resolvedStrings = resolveMatrixAccountStringValues({
|
||||
accountId: normalizedAccountId,
|
||||
account: {
|
||||
homeserver: accountField("homeserver"),
|
||||
userId: accountField("userId"),
|
||||
accessToken: accountField("accessToken"),
|
||||
password: accountField("password"),
|
||||
deviceId: accountField("deviceId"),
|
||||
deviceName: accountField("deviceName"),
|
||||
},
|
||||
scopedEnv,
|
||||
channel: {
|
||||
homeserver: readMatrixBaseConfigField(matrix, "homeserver"),
|
||||
userId: readMatrixBaseConfigField(matrix, "userId"),
|
||||
accessToken: readMatrixBaseConfigField(matrix, "accessToken"),
|
||||
password: readMatrixBaseConfigField(matrix, "password"),
|
||||
deviceId: readMatrixBaseConfigField(matrix, "deviceId"),
|
||||
deviceName: readMatrixBaseConfigField(matrix, "deviceName"),
|
||||
},
|
||||
globalEnv,
|
||||
});
|
||||
|
||||
const accountInitialSyncLimit = clampMatrixInitialSyncLimit(account.initialSyncLimit);
|
||||
const initialSyncLimit =
|
||||
accountInitialSyncLimit ?? clampMatrixInitialSyncLimit(matrix.initialSyncLimit);
|
||||
const encryption =
|
||||
typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false);
|
||||
|
||||
return {
|
||||
homeserver: resolvedStrings.homeserver,
|
||||
userId: resolvedStrings.userId,
|
||||
accessToken: resolvedStrings.accessToken || undefined,
|
||||
password: resolvedStrings.password || undefined,
|
||||
deviceId: resolvedStrings.deviceId || undefined,
|
||||
deviceName: resolvedStrings.deviceName || undefined,
|
||||
initialSyncLimit,
|
||||
encryption,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitMatrixAccountId(
|
||||
cfg: CoreConfig,
|
||||
_env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAuthContext(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): {
|
||||
cfg: CoreConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
accountId: string;
|
||||
resolved: MatrixResolvedConfig;
|
||||
} {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
|
||||
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
|
||||
if (!effectiveAccountId) {
|
||||
throw new Error(
|
||||
'Multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended account or pass --account <id>.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
explicitAccountId &&
|
||||
explicitAccountId !== DEFAULT_ACCOUNT_ID &&
|
||||
!listNormalizedMatrixAccountIds(cfg).includes(explicitAccountId) &&
|
||||
!hasScopedMatrixEnvConfig(explicitAccountId, env)
|
||||
) {
|
||||
throw new Error(
|
||||
`Matrix account "${explicitAccountId}" is not configured. Add channels.matrix.accounts.${explicitAccountId} or define scoped ${getMatrixScopedEnvVarNames(explicitAccountId).accessToken.replace(/_ACCESS_TOKEN$/, "")}_* variables.`,
|
||||
);
|
||||
}
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
|
||||
|
||||
return {
|
||||
cfg,
|
||||
env,
|
||||
accountId: effectiveAccountId,
|
||||
resolved,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixAuth(params?: {
|
||||
@@ -105,12 +336,8 @@ export async function resolveMatrixAuth(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env);
|
||||
if (!resolved.homeserver) {
|
||||
throw new Error("Matrix homeserver is required (matrix.homeserver)");
|
||||
}
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||
const homeserver = validateMatrixHomeserverUrl(resolved.homeserver);
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
@@ -119,13 +346,13 @@ export async function resolveMatrixAuth(params?: {
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const accountId = params?.accountId;
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
cached &&
|
||||
credentialsMatchConfig(cached, {
|
||||
homeserver: resolved.homeserver,
|
||||
homeserver,
|
||||
userId: resolved.userId || "",
|
||||
accessToken: resolved.accessToken,
|
||||
})
|
||||
? cached
|
||||
: null;
|
||||
@@ -133,30 +360,57 @@ export async function resolveMatrixAuth(params?: {
|
||||
// If we have an access token, we can fetch userId via whoami if not provided
|
||||
if (resolved.accessToken) {
|
||||
let userId = resolved.userId;
|
||||
if (!userId) {
|
||||
// Fetch userId from access token via whoami
|
||||
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 { MatrixClient } = loadMatrixSdk();
|
||||
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
|
||||
const whoami = await tempClient.getUserId();
|
||||
userId = whoami;
|
||||
// Save the credentials with the fetched userId
|
||||
saveMatrixCredentials(
|
||||
const tempClient = new MatrixClient(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,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
deviceId: knownDeviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
);
|
||||
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
} else if (hasMatchingCachedToken) {
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
homeserver: resolved.homeserver,
|
||||
accountId,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken: resolved.accessToken,
|
||||
password: resolved.password,
|
||||
deviceId: knownDeviceId,
|
||||
deviceName: resolved.deviceName,
|
||||
initialSyncLimit: resolved.initialSyncLimit,
|
||||
encryption: resolved.encryption,
|
||||
@@ -164,11 +418,14 @@ export async function resolveMatrixAuth(params?: {
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
touchMatrixCredentials(env, accountId);
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
return {
|
||||
accountId,
|
||||
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,
|
||||
@@ -185,36 +442,20 @@ export async function resolveMatrixAuth(params?: {
|
||||
);
|
||||
}
|
||||
|
||||
// Login with password using HTTP API.
|
||||
const { response: loginResponse, release: releaseLoginResponse } = await fetchWithSsrFGuard({
|
||||
url: `${resolved.homeserver}/_matrix/client/v3/login`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "m.login.password",
|
||||
identifier: { type: "m.id.user", user: resolved.userId },
|
||||
password: resolved.password,
|
||||
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
|
||||
}),
|
||||
},
|
||||
auditContext: "matrix.login",
|
||||
});
|
||||
const login = await (async () => {
|
||||
try {
|
||||
if (!loginResponse.ok) {
|
||||
const errorText = await loginResponse.text();
|
||||
throw new Error(`Matrix login failed: ${errorText}`);
|
||||
}
|
||||
return (await loginResponse.json()) as {
|
||||
access_token?: string;
|
||||
user_id?: string;
|
||||
device_id?: string;
|
||||
};
|
||||
} finally {
|
||||
await releaseLoginResponse();
|
||||
}
|
||||
})();
|
||||
// Login with password using the same hardened request path as other Matrix HTTP calls.
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const loginClient = new MatrixClient(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) {
|
||||
@@ -222,20 +463,23 @@ export async function resolveMatrixAuth(params?: {
|
||||
}
|
||||
|
||||
const auth: MatrixAuth = {
|
||||
homeserver: resolved.homeserver,
|
||||
accountId,
|
||||
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,
|
||||
};
|
||||
|
||||
saveMatrixCredentials(
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
deviceId: login.device_id,
|
||||
deviceId: auth.deviceId,
|
||||
},
|
||||
env,
|
||||
accountId,
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import type {
|
||||
IStorageProvider,
|
||||
ICryptoStorageProvider,
|
||||
MatrixClient,
|
||||
} from "@vector-im/matrix-bot-sdk";
|
||||
import { ensureMatrixCryptoRuntime } from "../deps.js";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { validateMatrixHomeserverUrl } from "./config.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import {
|
||||
maybeMigrateLegacyStorage,
|
||||
@@ -13,115 +8,59 @@ import {
|
||||
writeStorageMeta,
|
||||
} from "./storage.js";
|
||||
|
||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||
const LogService = loadMatrixSdk().LogService;
|
||||
if (input == null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(input)) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Expected ${label} list to be an array, got ${typeof input}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const filtered = input.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
);
|
||||
if (filtered.length !== input.length) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export async function createMatrixClient(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
userId?: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
initialSyncLimit?: number;
|
||||
accountId?: string | null;
|
||||
autoBootstrapCrypto?: boolean;
|
||||
}): Promise<MatrixClient> {
|
||||
await ensureMatrixCryptoRuntime();
|
||||
const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } =
|
||||
loadMatrixSdk();
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
const homeserver = validateMatrixHomeserverUrl(params.homeserver);
|
||||
const userId = params.userId?.trim() || "unknown";
|
||||
const matrixClientUserId = params.userId?.trim() || undefined;
|
||||
|
||||
// Create storage provider
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
homeserver,
|
||||
userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
deviceId: params.deviceId,
|
||||
env,
|
||||
});
|
||||
await maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
});
|
||||
maybeMigrateLegacyStorage({ storagePaths, env });
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
|
||||
|
||||
// Create crypto storage if encryption is enabled
|
||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||
if (params.encryption) {
|
||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
|
||||
} catch (err) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Failed to initialize crypto storage, E2EE disabled:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
writeStorageMeta({
|
||||
storagePaths,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
homeserver,
|
||||
userId,
|
||||
accountId: params.accountId,
|
||||
deviceId: params.deviceId,
|
||||
});
|
||||
|
||||
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
|
||||
const cryptoDatabasePrefix = `openclaw-matrix-${storagePaths.accountKey}-${storagePaths.tokenHash}`;
|
||||
|
||||
if (client.crypto) {
|
||||
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
|
||||
client.crypto.updateSyncData = async (
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
changedDeviceLists,
|
||||
leftDeviceLists,
|
||||
) => {
|
||||
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
|
||||
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
|
||||
try {
|
||||
return await originalUpdateSyncData(
|
||||
toDeviceMessages,
|
||||
otkCounts,
|
||||
unusedFallbackKeyAlgs,
|
||||
safeChanged,
|
||||
safeLeft,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
|
||||
if (message.includes("Expect value to be String")) {
|
||||
LogService.warn(
|
||||
"MatrixClientLite",
|
||||
"Ignoring malformed device list entries during crypto sync",
|
||||
message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return client;
|
||||
return new MatrixClient(homeserver, params.accessToken, undefined, undefined, {
|
||||
userId: matrixClientUserId,
|
||||
password: params.password,
|
||||
deviceId: params.deviceId,
|
||||
encryption: params.encryption,
|
||||
localTimeoutMs: params.localTimeoutMs,
|
||||
initialSyncLimit: params.initialSyncLimit,
|
||||
storagePath: storagePaths.storagePath,
|
||||
recoveryKeyPath: storagePaths.recoveryKeyPath,
|
||||
idbSnapshotPath: storagePaths.idbSnapshotPath,
|
||||
cryptoDatabasePrefix,
|
||||
autoBootstrapCrypto: params.autoBootstrapCrypto,
|
||||
});
|
||||
}
|
||||
|
||||
197
extensions/matrix/src/matrix/client/file-sync-store.test.ts
Normal file
197
extensions/matrix/src/matrix/client/file-sync-store.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ISyncResponse } from "matrix-js-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as jsonFiles from "../../../../../src/infra/json-files.js";
|
||||
import { FileBackedMatrixSyncStore } from "./file-sync-store.js";
|
||||
|
||||
function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
return {
|
||||
next_batch: nextBatch,
|
||||
rooms: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {},
|
||||
state: { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
content: {
|
||||
body: "hello",
|
||||
msgtype: "m.text",
|
||||
},
|
||||
event_id: "$message",
|
||||
origin_server_ts: 1,
|
||||
sender: "@user:example.org",
|
||||
type: "m.room.message",
|
||||
},
|
||||
],
|
||||
prev_batch: "t0",
|
||||
},
|
||||
ephemeral: { events: [] },
|
||||
account_data: { events: [] },
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
{
|
||||
content: { theme: "dark" },
|
||||
type: "com.openclaw.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("FileBackedMatrixSyncStore", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists sync data so restart resumes from the saved cursor", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(firstStore.hasSavedSync()).toBe(false);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
await firstStore.flush();
|
||||
|
||||
const secondStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(secondStore.hasSavedSync()).toBe(true);
|
||||
await expect(secondStore.getSavedSyncToken()).resolves.toBe("s123");
|
||||
|
||||
const savedSync = await secondStore.getSavedSync();
|
||||
expect(savedSync?.nextBatch).toBe("s123");
|
||||
expect(savedSync?.accountData).toEqual([
|
||||
{
|
||||
content: { theme: "dark" },
|
||||
type: "com.openclaw.test",
|
||||
},
|
||||
]);
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("coalesces background persistence until the debounce window elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
const writeSpy = vi.spyOn(jsonFiles, "writeJsonAtomic").mockResolvedValue();
|
||||
|
||||
const store = new FileBackedMatrixSyncStore(storagePath);
|
||||
await store.setSyncData(createSyncResponse("s111"));
|
||||
await store.setSyncData(createSyncResponse("s222"));
|
||||
await store.storeClientOptions({ lazyLoadMembers: true });
|
||||
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(249);
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
storagePath,
|
||||
expect.objectContaining({
|
||||
savedSync: expect.objectContaining({
|
||||
nextBatch: "s222",
|
||||
}),
|
||||
clientOptions: {
|
||||
lazyLoadMembers: true,
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for an in-flight persist when shutdown flush runs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
const writeDeferred = createDeferred();
|
||||
const writeSpy = vi
|
||||
.spyOn(jsonFiles, "writeJsonAtomic")
|
||||
.mockImplementation(async () => writeDeferred.promise);
|
||||
|
||||
const store = new FileBackedMatrixSyncStore(storagePath);
|
||||
await store.setSyncData(createSyncResponse("s777"));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
let flushCompleted = false;
|
||||
const flushPromise = store.flush().then(() => {
|
||||
flushCompleted = true;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(flushCompleted).toBe(false);
|
||||
|
||||
writeDeferred.resolve();
|
||||
await flushPromise;
|
||||
expect(flushCompleted).toBe(true);
|
||||
});
|
||||
|
||||
it("persists client options alongside sync state", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.storeClientOptions({ lazyLoadMembers: true });
|
||||
await firstStore.flush();
|
||||
|
||||
const secondStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await expect(secondStore.getClientOptions()).resolves.toEqual({ lazyLoadMembers: true });
|
||||
});
|
||||
|
||||
it("loads legacy raw sync payloads from bot-storage.json", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
fs.writeFileSync(
|
||||
storagePath,
|
||||
JSON.stringify({
|
||||
next_batch: "legacy-token",
|
||||
rooms: {
|
||||
join: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(store.hasSavedSync()).toBe(true);
|
||||
await expect(store.getSavedSyncToken()).resolves.toBe("legacy-token");
|
||||
await expect(store.getSavedSync()).resolves.toMatchObject({
|
||||
nextBatch: "legacy-token",
|
||||
roomsData: {
|
||||
join: {},
|
||||
},
|
||||
accountData: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
256
extensions/matrix/src/matrix/client/file-sync-store.ts
Normal file
256
extensions/matrix/src/matrix/client/file-sync-store.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
MemoryStore,
|
||||
SyncAccumulator,
|
||||
type ISyncData,
|
||||
type ISyncResponse,
|
||||
type IStoredClientOpts,
|
||||
} from "matrix-js-sdk";
|
||||
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const PERSIST_DEBOUNCE_MS = 250;
|
||||
|
||||
type PersistedMatrixSyncStore = {
|
||||
version: number;
|
||||
savedSync: ISyncData | null;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
};
|
||||
|
||||
function createAsyncLock() {
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
return async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const previous = lock;
|
||||
let release: (() => void) | undefined;
|
||||
lock = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value.nextBatch === "string" && value.nextBatch.trim()) {
|
||||
if (!Array.isArray(value.accountData) || !isRecord(value.roomsData)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nextBatch: value.nextBatch,
|
||||
accountData: value.accountData,
|
||||
roomsData: value.roomsData,
|
||||
} as ISyncData;
|
||||
}
|
||||
|
||||
// Older Matrix state files stored the raw /sync-shaped payload directly.
|
||||
if (typeof value.next_batch === "string" && value.next_batch.trim()) {
|
||||
return {
|
||||
nextBatch: value.next_batch,
|
||||
accountData:
|
||||
isRecord(value.account_data) && Array.isArray(value.account_data.events)
|
||||
? value.account_data.events
|
||||
: [],
|
||||
roomsData: isRecord(value.rooms) ? value.rooms : {},
|
||||
} as ISyncData;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
version?: unknown;
|
||||
savedSync?: unknown;
|
||||
clientOptions?: unknown;
|
||||
};
|
||||
const savedSync = toPersistedSyncData(parsed.savedSync);
|
||||
if (parsed.version === STORE_VERSION) {
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync,
|
||||
clientOptions: isRecord(parsed.clientOptions)
|
||||
? (parsed.clientOptions as IStoredClientOpts)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Backward-compat: prior Matrix state files stored the raw sync blob at the
|
||||
// top level without versioning or wrapped metadata.
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(parsed),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function syncDataToSyncResponse(syncData: ISyncData): ISyncResponse {
|
||||
return {
|
||||
next_batch: syncData.nextBatch,
|
||||
rooms: syncData.roomsData,
|
||||
account_data: {
|
||||
events: syncData.accountData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
private readonly persistLock = createAsyncLock();
|
||||
private readonly accumulator = new SyncAccumulator();
|
||||
private savedSync: ISyncData | null = null;
|
||||
private savedClientOptions: IStoredClientOpts | undefined;
|
||||
private readonly hadSavedSyncOnLoad: boolean;
|
||||
private dirty = false;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private persistPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(private readonly storagePath: string) {
|
||||
super();
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
try {
|
||||
const raw = readFileSync(this.storagePath, "utf8");
|
||||
const persisted = readPersistedStore(raw);
|
||||
restoredSavedSync = persisted?.savedSync ?? null;
|
||||
restoredClientOptions = persisted?.clientOptions;
|
||||
} catch {
|
||||
// Missing or unreadable sync cache should not block startup.
|
||||
}
|
||||
|
||||
this.savedSync = restoredSavedSync;
|
||||
this.savedClientOptions = restoredClientOptions;
|
||||
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
|
||||
|
||||
if (this.savedSync) {
|
||||
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
|
||||
super.setSyncToken(this.savedSync.nextBatch);
|
||||
}
|
||||
if (this.savedClientOptions) {
|
||||
void super.storeClientOptions(this.savedClientOptions);
|
||||
}
|
||||
}
|
||||
|
||||
hasSavedSync(): boolean {
|
||||
return this.hadSavedSyncOnLoad;
|
||||
}
|
||||
|
||||
override getSavedSync(): Promise<ISyncData | null> {
|
||||
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
|
||||
}
|
||||
|
||||
override getSavedSyncToken(): Promise<string | null> {
|
||||
return Promise.resolve(this.savedSync?.nextBatch ?? null);
|
||||
}
|
||||
|
||||
override setSyncData(syncData: ISyncResponse): Promise<void> {
|
||||
this.accumulator.accumulate(syncData);
|
||||
this.savedSync = this.accumulator.getJSON();
|
||||
this.markDirtyAndSchedulePersist();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override getClientOptions() {
|
||||
return Promise.resolve(
|
||||
this.savedClientOptions ? cloneJson(this.savedClientOptions) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
override storeClientOptions(options: IStoredClientOpts) {
|
||||
this.savedClientOptions = cloneJson(options);
|
||||
void super.storeClientOptions(options);
|
||||
this.markDirtyAndSchedulePersist();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override save(force = false) {
|
||||
if (force) {
|
||||
return this.flush();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override wantsSave(): boolean {
|
||||
// We persist directly from setSyncData/storeClientOptions so the SDK's
|
||||
// periodic save hook stays disabled. Shutdown uses flush() for a final sync.
|
||||
return false;
|
||||
}
|
||||
|
||||
override async deleteAllData(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
this.dirty = false;
|
||||
await this.persistPromise?.catch(() => undefined);
|
||||
await super.deleteAllData();
|
||||
this.savedSync = null;
|
||||
this.savedClientOptions = undefined;
|
||||
await fs.rm(this.storagePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
this.persistTimer = null;
|
||||
}
|
||||
while (this.dirty || this.persistPromise) {
|
||||
if (this.dirty && !this.persistPromise) {
|
||||
this.persistPromise = this.persist().finally(() => {
|
||||
this.persistPromise = null;
|
||||
});
|
||||
}
|
||||
await this.persistPromise;
|
||||
}
|
||||
}
|
||||
|
||||
private markDirtyAndSchedulePersist(): void {
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
void this.flush().catch((err) => {
|
||||
LogService.warn("MatrixFileSyncStore", "Failed to persist Matrix sync store:", err);
|
||||
});
|
||||
}, PERSIST_DEBOUNCE_MS);
|
||||
this.persistTimer.unref?.();
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
this.dirty = false;
|
||||
const payload: PersistedMatrixSyncStore = {
|
||||
version: STORE_VERSION,
|
||||
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
|
||||
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
|
||||
};
|
||||
try {
|
||||
await this.persistLock(async () => {
|
||||
await writeJsonFileAtomically(this.storagePath, payload);
|
||||
});
|
||||
} catch (err) {
|
||||
this.dirty = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkBaseLogger:
|
||||
| {
|
||||
trace: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
debug: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
info: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
warn: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
error: (module: string, ...messageOrObject: unknown[]) => void;
|
||||
}
|
||||
| undefined;
|
||||
let matrixSdkLogMode: "default" | "quiet" = "default";
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
const matrixSdkSilentMethodFactory = () => () => {};
|
||||
let matrixSdkRootMethodFactory: unknown;
|
||||
let matrixSdkRootLoggerInitialized = false;
|
||||
|
||||
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 !== "MatrixHttpClient") {
|
||||
if (!module.includes("MatrixHttpClient")) {
|
||||
return false;
|
||||
}
|
||||
return messageOrObject.some((entry) => {
|
||||
@@ -24,23 +30,94 @@ function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unkno
|
||||
}
|
||||
|
||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||
if (matrixSdkLoggingConfigured) {
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
matrixSdkLoggingConfigured = true;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function setMatrixSdkLogMode(mode: "default" | "quiet"): void {
|
||||
matrixSdkLogMode = mode;
|
||||
if (!matrixSdkLoggingConfigured) {
|
||||
return;
|
||||
}
|
||||
applyMatrixSdkLogger();
|
||||
}
|
||||
|
||||
export function setMatrixSdkConsoleLogging(enabled: boolean): void {
|
||||
setMatrixConsoleLogging(enabled);
|
||||
}
|
||||
|
||||
export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLogger {
|
||||
return createMatrixJsSdkLoggerInstance(prefix);
|
||||
}
|
||||
|
||||
function applyMatrixJsSdkRootLoggerMode(): void {
|
||||
const rootLogger = matrixJsSdkRootLogger as {
|
||||
methodFactory?: unknown;
|
||||
rebuild?: () => void;
|
||||
};
|
||||
if (!matrixSdkRootLoggerInitialized) {
|
||||
matrixSdkRootMethodFactory = rootLogger.methodFactory;
|
||||
matrixSdkRootLoggerInitialized = true;
|
||||
}
|
||||
rootLogger.methodFactory =
|
||||
matrixSdkLogMode === "quiet" ? matrixSdkSilentMethodFactory : matrixSdkRootMethodFactory;
|
||||
rootLogger.rebuild?.();
|
||||
}
|
||||
|
||||
function applyMatrixSdkLogger(): void {
|
||||
applyMatrixJsSdkRootLoggerMode();
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
LogService.setLogger({
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { ConsoleLogger, LogService } = loadMatrixSdk();
|
||||
matrixSdkBaseLogger = new ConsoleLogger();
|
||||
matrixSdkLoggingConfigured = true;
|
||||
|
||||
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),
|
||||
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);
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,85 +1,228 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSharedMatrixClient, stopSharedClient } from "./shared.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
|
||||
const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn());
|
||||
const createMatrixClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./create-client.js", () => ({
|
||||
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveMatrixAuth: resolveMatrixAuthMock,
|
||||
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
|
||||
}));
|
||||
|
||||
function makeAuth(suffix: string): MatrixAuth {
|
||||
vi.mock("./create-client.js", () => ({
|
||||
createMatrixClient: createMatrixClientMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
acquireSharedMatrixClient,
|
||||
releaseSharedClientInstance,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
stopSharedClientInstance,
|
||||
} from "./shared.js";
|
||||
|
||||
function authFor(accountId: string): MatrixAuth {
|
||||
return {
|
||||
accountId,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: `@bot-${suffix}:example.org`,
|
||||
accessToken: `token-${suffix}`,
|
||||
userId: `@${accountId}:example.org`,
|
||||
accessToken: `token-${accountId}`,
|
||||
password: "secret", // pragma: allowlist secret
|
||||
deviceId: `${accountId.toUpperCase()}-DEVICE`,
|
||||
deviceName: `${accountId} device`,
|
||||
initialSyncLimit: undefined,
|
||||
encryption: false,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockClient(startImpl: () => Promise<void>): MatrixClient {
|
||||
return {
|
||||
start: vi.fn(startImpl),
|
||||
stop: vi.fn(),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([]),
|
||||
function createMockClient(name: string) {
|
||||
const client = {
|
||||
name,
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
getJoinedRooms: vi.fn(async () => [] as string[]),
|
||||
crypto: undefined,
|
||||
} as unknown as MatrixClient;
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("resolveSharedMatrixClient startup behavior", () => {
|
||||
describe("resolveSharedMatrixClient", () => {
|
||||
beforeEach(() => {
|
||||
resolveMatrixAuthMock.mockReset();
|
||||
resolveMatrixAuthContextMock.mockReset();
|
||||
createMatrixClientMock.mockReset();
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ accountId }: { accountId?: string | null } = {}) => ({
|
||||
cfg: undefined,
|
||||
env: undefined,
|
||||
accountId: accountId ?? "default",
|
||||
resolved: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stopSharedClient();
|
||||
createMatrixClientMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("propagates the original start error during initialization", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startError = new Error("bad token");
|
||||
const client = createMockClient(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
setTimeout(() => reject(startError), 1);
|
||||
}),
|
||||
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.mockResolvedValue(client);
|
||||
|
||||
const startPromise = resolveSharedMatrixClient({
|
||||
auth: makeAuth("start-error"),
|
||||
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
|
||||
if (accountId === "ops") {
|
||||
return poeClient;
|
||||
}
|
||||
return mainClient;
|
||||
});
|
||||
const startExpectation = expect(startPromise).rejects.toBe(startError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
await startExpectation;
|
||||
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("retries start after a late start-loop failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
let rejectFirstStart: ((err: unknown) => void) | undefined;
|
||||
const firstStart = new Promise<void>((_resolve, reject) => {
|
||||
rejectFirstStart = reject;
|
||||
});
|
||||
const secondStart = new Promise<void>(() => {});
|
||||
const startMock = vi.fn().mockReturnValueOnce(firstStart).mockReturnValueOnce(secondStart);
|
||||
const client = createMockClient(startMock);
|
||||
createMatrixClientMock.mockResolvedValue(client);
|
||||
it("stops only the targeted account client", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const poeAuth = authFor("ops");
|
||||
const mainClient = createMockClient("main");
|
||||
const poeClient = createMockClient("ops");
|
||||
|
||||
const firstResolve = resolveSharedMatrixClient({
|
||||
auth: makeAuth("late-failure"),
|
||||
resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) =>
|
||||
accountId === "ops" ? poeAuth : mainAuth,
|
||||
);
|
||||
createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => {
|
||||
if (accountId === "ops") {
|
||||
return poeClient;
|
||||
}
|
||||
return mainClient;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(firstResolve).resolves.toBe(client);
|
||||
expect(startMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
rejectFirstStart?.(new Error("late failure"));
|
||||
await Promise.resolve();
|
||||
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
|
||||
|
||||
const secondResolve = resolveSharedMatrixClient({
|
||||
auth: makeAuth("late-failure"),
|
||||
stopSharedClientForAccount(mainAuth);
|
||||
|
||||
expect(mainClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(poeClient.stop).toHaveBeenCalledTimes(0);
|
||||
|
||||
stopSharedClient();
|
||||
|
||||
expect(poeClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops stopped shared clients by instance so the next resolve recreates them", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const firstMainClient = createMockClient("main-first");
|
||||
const secondMainClient = createMockClient("main-second");
|
||||
|
||||
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
|
||||
createMatrixClientMock
|
||||
.mockResolvedValueOnce(firstMainClient)
|
||||
.mockResolvedValueOnce(secondMainClient);
|
||||
|
||||
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
stopSharedClientInstance(first as unknown as import("../sdk.js").MatrixClient);
|
||||
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
|
||||
expect(first).toBe(firstMainClient);
|
||||
expect(second).toBe(secondMainClient);
|
||||
expect(firstMainClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reuses the effective implicit account instead of keying it as default", async () => {
|
||||
const poeAuth = authFor("ops");
|
||||
const poeClient = createMockClient("ops");
|
||||
|
||||
resolveMatrixAuthContextMock.mockReturnValue({
|
||||
cfg: undefined,
|
||||
env: undefined,
|
||||
accountId: "ops",
|
||||
resolved: {},
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(secondResolve).resolves.toBe(client);
|
||||
expect(startMock).toHaveBeenCalledTimes(2);
|
||||
resolveMatrixAuthMock.mockResolvedValue(poeAuth);
|
||||
createMatrixClientMock.mockResolvedValue(poeClient);
|
||||
|
||||
const first = await resolveSharedMatrixClient({ startClient: false });
|
||||
const second = await resolveSharedMatrixClient({ startClient: false });
|
||||
|
||||
expect(first).toBe(poeClient);
|
||||
expect(second).toBe(poeClient);
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
cfg: undefined,
|
||||
env: undefined,
|
||||
accountId: "ops",
|
||||
});
|
||||
expect(createMatrixClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(createMatrixClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("honors startClient false even when the caller acquires a shared lease", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const mainClient = createMockClient("main");
|
||||
|
||||
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
|
||||
createMatrixClientMock.mockResolvedValue(mainClient);
|
||||
|
||||
const client = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
|
||||
expect(client).toBe(mainClient);
|
||||
expect(mainClient.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps shared clients alive until the last one-off lease releases", async () => {
|
||||
const mainAuth = authFor("main");
|
||||
const mainClient = {
|
||||
...createMockClient("main"),
|
||||
stopAndPersist: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
|
||||
createMatrixClientMock.mockResolvedValue(mainClient);
|
||||
|
||||
const first = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
const second = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
|
||||
|
||||
expect(first).toBe(mainClient);
|
||||
expect(second).toBe(mainClient);
|
||||
|
||||
expect(
|
||||
await releaseSharedClientInstance(mainClient as unknown as import("../sdk.js").MatrixClient),
|
||||
).toBe(false);
|
||||
expect(mainClient.stop).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
await releaseSharedClientInstance(mainClient as unknown as import("../sdk.js").MatrixClient),
|
||||
).toBe(true);
|
||||
expect(mainClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects mismatched explicit account ids when auth is already resolved", async () => {
|
||||
await expect(
|
||||
resolveSharedMatrixClient({
|
||||
auth: authFor("ops"),
|
||||
accountId: "main",
|
||||
startClient: false,
|
||||
}),
|
||||
).rejects.toThrow("Matrix shared client account mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixLogService } from "../sdk-runtime.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { LogService } from "../sdk/logger.js";
|
||||
import { resolveMatrixAuth, resolveMatrixAuthContext } from "./config.js";
|
||||
import { createMatrixClient } from "./create-client.js";
|
||||
import { startMatrixClientWithGrace } from "./startup.js";
|
||||
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
|
||||
type SharedMatrixClientState = {
|
||||
@@ -13,45 +11,62 @@ type SharedMatrixClientState = {
|
||||
key: string;
|
||||
started: boolean;
|
||||
cryptoReady: boolean;
|
||||
startPromise: Promise<void> | null;
|
||||
leases: number;
|
||||
};
|
||||
|
||||
// Support multiple accounts with separate clients
|
||||
const sharedClientStates = new Map<string, SharedMatrixClientState>();
|
||||
const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>();
|
||||
const sharedClientStartPromises = new Map<string, Promise<void>>();
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
normalizedAccountId || DEFAULT_ACCOUNT_KEY,
|
||||
auth.accountId,
|
||||
].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,
|
||||
accountId: params.accountId,
|
||||
initialSyncLimit: params.auth.initialSyncLimit,
|
||||
accountId: params.auth.accountId,
|
||||
});
|
||||
return {
|
||||
client,
|
||||
key: buildSharedClientKey(params.auth, params.accountId),
|
||||
key: buildSharedClientKey(params.auth),
|
||||
started: false,
|
||||
cryptoReady: false,
|
||||
startPromise: null,
|
||||
leases: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function findSharedClientStateByInstance(client: MatrixClient): SharedMatrixClientState | null {
|
||||
for (const state of sharedClientStates.values()) {
|
||||
if (state.client === client) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deleteSharedClientState(state: SharedMatrixClientState): void {
|
||||
sharedClientStates.delete(state.key);
|
||||
sharedClientPromises.delete(state.key);
|
||||
}
|
||||
|
||||
async function ensureSharedClientStarted(params: {
|
||||
state: SharedMatrixClientState;
|
||||
timeoutMs?: number;
|
||||
@@ -61,13 +76,12 @@ async function ensureSharedClientStarted(params: {
|
||||
if (params.state.started) {
|
||||
return;
|
||||
}
|
||||
const key = params.state.key;
|
||||
const existingStartPromise = sharedClientStartPromises.get(key);
|
||||
if (existingStartPromise) {
|
||||
await existingStartPromise;
|
||||
if (params.state.startPromise) {
|
||||
await params.state.startPromise;
|
||||
return;
|
||||
}
|
||||
const startPromise = (async () => {
|
||||
|
||||
params.state.startPromise = (async () => {
|
||||
const client = params.state.client;
|
||||
|
||||
// Initialize crypto if enabled
|
||||
@@ -75,32 +89,105 @@ async function ensureSharedClientStarted(params: {
|
||||
try {
|
||||
const joinedRooms = await client.getJoinedRooms();
|
||||
if (client.crypto) {
|
||||
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
|
||||
joinedRooms,
|
||||
);
|
||||
await client.crypto.prepare(joinedRooms);
|
||||
params.state.cryptoReady = true;
|
||||
}
|
||||
} catch (err) {
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
|
||||
}
|
||||
}
|
||||
|
||||
await startMatrixClientWithGrace({
|
||||
client,
|
||||
onError: (err: unknown) => {
|
||||
params.state.started = false;
|
||||
const LogService = getMatrixLogService();
|
||||
LogService.error("MatrixClientLite", "client.start() error:", err);
|
||||
},
|
||||
});
|
||||
await client.start();
|
||||
params.state.started = true;
|
||||
})();
|
||||
sharedClientStartPromises.set(key, startPromise);
|
||||
|
||||
try {
|
||||
await startPromise;
|
||||
await params.state.startPromise;
|
||||
} finally {
|
||||
sharedClientStartPromises.delete(key);
|
||||
params.state.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSharedMatrixClientState(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<SharedMatrixClientState> {
|
||||
const requestedAccountId = normalizeOptionalAccountId(params.accountId);
|
||||
if (params.auth && requestedAccountId && requestedAccountId !== params.auth.accountId) {
|
||||
throw new Error(
|
||||
`Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`,
|
||||
);
|
||||
}
|
||||
const authContext = params.auth
|
||||
? null
|
||||
: resolveMatrixAuthContext({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const auth =
|
||||
params.auth ??
|
||||
(await resolveMatrixAuth({
|
||||
cfg: authContext?.cfg ?? params.cfg,
|
||||
env: authContext?.env ?? params.env,
|
||||
accountId: authContext?.accountId,
|
||||
}));
|
||||
const key = buildSharedClientKey(auth);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const creationPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
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;
|
||||
} finally {
|
||||
sharedClientPromises.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,97 +201,76 @@ export async function resolveSharedMatrixClient(
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const auth =
|
||||
params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
// Check if we already have a client for this key
|
||||
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;
|
||||
}
|
||||
|
||||
// Check if there's a pending creation for this key
|
||||
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;
|
||||
}
|
||||
|
||||
// Create a new client for this account
|
||||
const createPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId,
|
||||
});
|
||||
sharedClientPromises.set(key, createPromise);
|
||||
try {
|
||||
const created = await createPromise;
|
||||
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);
|
||||
}
|
||||
const state = await resolveSharedMatrixClientState(params);
|
||||
return state.client;
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// @vector-im/matrix-bot-sdk handles sync internally in start()
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
export async function acquireSharedMatrixClient(
|
||||
params: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const state = await resolveSharedMatrixClientState(params);
|
||||
state.leases += 1;
|
||||
return state.client;
|
||||
}
|
||||
|
||||
export function stopSharedClient(key?: string): void {
|
||||
if (key) {
|
||||
// Stop a specific client
|
||||
const state = sharedClientStates.get(key);
|
||||
if (state) {
|
||||
state.client.stop();
|
||||
sharedClientStates.delete(key);
|
||||
}
|
||||
export function stopSharedClient(): void {
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
}
|
||||
sharedClientStates.clear();
|
||||
sharedClientPromises.clear();
|
||||
}
|
||||
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth): void {
|
||||
const key = buildSharedClientKey(auth);
|
||||
const state = sharedClientStates.get(key);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state.client.stop();
|
||||
deleteSharedClientState(state);
|
||||
}
|
||||
|
||||
export function removeSharedClientInstance(client: MatrixClient): boolean {
|
||||
const state = findSharedClientStateByInstance(client);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
deleteSharedClientState(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function stopSharedClientInstance(client: MatrixClient): void {
|
||||
if (!removeSharedClientInstance(client)) {
|
||||
return;
|
||||
}
|
||||
client.stop();
|
||||
}
|
||||
|
||||
export async function releaseSharedClientInstance(
|
||||
client: MatrixClient,
|
||||
mode: "stop" | "persist" = "stop",
|
||||
): Promise<boolean> {
|
||||
const state = findSharedClientStateByInstance(client);
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
state.leases = Math.max(0, state.leases - 1);
|
||||
if (state.leases > 0) {
|
||||
return false;
|
||||
}
|
||||
deleteSharedClientState(state);
|
||||
if (mode === "persist") {
|
||||
await client.stopAndPersist();
|
||||
} else {
|
||||
// Stop all clients (backward compatible behavior)
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
}
|
||||
sharedClientStates.clear();
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the shared client for a specific account.
|
||||
* Use this instead of stopSharedClient() when shutting down a single account
|
||||
* to avoid stopping all accounts.
|
||||
*/
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
|
||||
const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
|
||||
stopSharedClient(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MATRIX_CLIENT_STARTUP_GRACE_MS, startMatrixClientWithGrace } from "./startup.js";
|
||||
|
||||
describe("startMatrixClientWithGrace", () => {
|
||||
it("resolves after grace when start loop keeps running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = {
|
||||
start: vi.fn().mockReturnValue(new Promise<void>(() => {})),
|
||||
};
|
||||
const startPromise = startMatrixClientWithGrace({ client });
|
||||
await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
|
||||
await expect(startPromise).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects when startup fails during grace", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startError = new Error("invalid token");
|
||||
const client = {
|
||||
start: vi.fn().mockRejectedValue(startError),
|
||||
};
|
||||
const startPromise = startMatrixClientWithGrace({ client });
|
||||
const startupExpectation = expect(startPromise).rejects.toBe(startError);
|
||||
await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
|
||||
await startupExpectation;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls onError for late failures after startup returns", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lateError = new Error("late disconnect");
|
||||
let rejectStart: ((err: unknown) => void) | undefined;
|
||||
const startLoop = new Promise<void>((_resolve, reject) => {
|
||||
rejectStart = reject;
|
||||
});
|
||||
const onError = vi.fn();
|
||||
const client = {
|
||||
start: vi.fn().mockReturnValue(startLoop),
|
||||
};
|
||||
const startPromise = startMatrixClientWithGrace({ client, onError });
|
||||
await vi.advanceTimersByTimeAsync(MATRIX_CLIENT_STARTUP_GRACE_MS);
|
||||
await expect(startPromise).resolves.toBeUndefined();
|
||||
|
||||
rejectStart?.(lateError);
|
||||
await Promise.resolve();
|
||||
expect(onError).toHaveBeenCalledWith(lateError);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
|
||||
export const MATRIX_CLIENT_STARTUP_GRACE_MS = 2000;
|
||||
|
||||
export async function startMatrixClientWithGrace(params: {
|
||||
client: Pick<MatrixClient, "start">;
|
||||
graceMs?: number;
|
||||
onError?: (err: unknown) => void;
|
||||
}): Promise<void> {
|
||||
const graceMs = params.graceMs ?? MATRIX_CLIENT_STARTUP_GRACE_MS;
|
||||
let startFailed = false;
|
||||
let startError: unknown = undefined;
|
||||
let startPromise: Promise<unknown>;
|
||||
try {
|
||||
startPromise = params.client.start();
|
||||
} catch (err) {
|
||||
params.onError?.(err);
|
||||
throw err;
|
||||
}
|
||||
void startPromise.catch((err: unknown) => {
|
||||
startFailed = true;
|
||||
startError = err;
|
||||
params.onError?.(err);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
||||
if (startFailed) {
|
||||
throw startError;
|
||||
}
|
||||
}
|
||||
496
extensions/matrix/src/matrix/client/storage.test.ts
Normal file
496
extensions/matrix/src/matrix/client/storage.test.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
const createBackupArchiveMock = vi.hoisted(() =>
|
||||
vi.fn(async (_params: unknown) => ({
|
||||
createdAt: "2026-03-17T00:00:00.000Z",
|
||||
archiveRoot: "2026-03-17-openclaw-backup",
|
||||
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
|
||||
dryRun: false,
|
||||
includeWorkspace: false,
|
||||
onlyConfig: false,
|
||||
verified: false,
|
||||
assets: [],
|
||||
skipped: [],
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../../../../../src/infra/backup-create.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../../src/infra/backup-create.js")>();
|
||||
return {
|
||||
...actual,
|
||||
createBackupArchive: (params: unknown) => createBackupArchiveMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
let maybeMigrateLegacyStorage: typeof import("./storage.js").maybeMigrateLegacyStorage;
|
||||
let resolveMatrixStoragePaths: typeof import("./storage.js").resolveMatrixStoragePaths;
|
||||
|
||||
describe("matrix client storage paths", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ maybeMigrateLegacyStorage, resolveMatrixStoragePaths } = await import("./storage.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
createBackupArchiveMock.mockReset();
|
||||
createBackupArchiveMock.mockImplementation(async (_params: unknown) => ({
|
||||
createdAt: "2026-03-17T00:00:00.000Z",
|
||||
archiveRoot: "2026-03-17-openclaw-backup",
|
||||
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
|
||||
dryRun: false,
|
||||
includeWorkspace: false,
|
||||
onlyConfig: false,
|
||||
verified: false,
|
||||
assets: [],
|
||||
skipped: [],
|
||||
}));
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setupStateDir(
|
||||
cfg: Record<string, unknown> = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
): string {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-storage-"));
|
||||
const stateDir = path.join(homeDir, ".openclaw");
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
tempDirs.push(homeDir);
|
||||
setMatrixRuntime({
|
||||
config: {
|
||||
loadConfig: () => cfg,
|
||||
},
|
||||
logging: {
|
||||
getChildLogger: () => ({
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
}),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: () => stateDir,
|
||||
},
|
||||
} as never);
|
||||
return stateDir;
|
||||
}
|
||||
|
||||
function createMigrationEnv(stateDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
HOME: path.dirname(stateDir),
|
||||
OPENCLAW_HOME: path.dirname(stateDir),
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_TEST_FAST: "1",
|
||||
} as NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
it("uses the simplified matrix runtime root for account-scoped storage", () => {
|
||||
const stateDir = setupStateDir();
|
||||
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@Bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
accountId: "ops",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(storagePaths.rootDir).toBe(
|
||||
path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"ops",
|
||||
"matrix.example.org__bot_example.org",
|
||||
storagePaths.tokenHash,
|
||||
),
|
||||
);
|
||||
expect(storagePaths.storagePath).toBe(path.join(storagePaths.rootDir, "bot-storage.json"));
|
||||
expect(storagePaths.cryptoPath).toBe(path.join(storagePaths.rootDir, "crypto"));
|
||||
expect(storagePaths.metaPath).toBe(path.join(storagePaths.rootDir, "storage-meta.json"));
|
||||
expect(storagePaths.recoveryKeyPath).toBe(path.join(storagePaths.rootDir, "recovery-key.json"));
|
||||
expect(storagePaths.idbSnapshotPath).toBe(
|
||||
path.join(storagePaths.rootDir, "crypto-idb-snapshot.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to migrating the older flat matrix storage layout", async () => {
|
||||
const stateDir = setupStateDir();
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||
const env = createMigrationEnv(stateDir);
|
||||
|
||||
await maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(createBackupArchiveMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeWorkspace: false }),
|
||||
);
|
||||
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(false);
|
||||
expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe('{"legacy":true}');
|
||||
expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("continues migrating whichever legacy artifact is still missing", async () => {
|
||||
const stateDir = setupStateDir();
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
const env = createMigrationEnv(stateDir);
|
||||
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(storagePaths.storagePath, '{"new":true}');
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
|
||||
await maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
});
|
||||
|
||||
expect(createBackupArchiveMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeWorkspace: false }),
|
||||
);
|
||||
expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe('{"new":true}');
|
||||
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(false);
|
||||
expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to migrate legacy storage when the snapshot step fails", async () => {
|
||||
const stateDir = setupStateDir();
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||
const env = createMigrationEnv(stateDir);
|
||||
createBackupArchiveMock.mockRejectedValueOnce(new Error("snapshot failed"));
|
||||
|
||||
await expect(
|
||||
maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
}),
|
||||
).rejects.toThrow("snapshot failed");
|
||||
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back moved legacy storage when the crypto move fails", async () => {
|
||||
const stateDir = setupStateDir();
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||
const env = createMigrationEnv(stateDir);
|
||||
const realRenameSync = fs.renameSync.bind(fs);
|
||||
const renameSync = vi.spyOn(fs, "renameSync");
|
||||
renameSync.mockImplementation((sourcePath, targetPath) => {
|
||||
if (String(targetPath) === storagePaths.cryptoPath) {
|
||||
throw new Error("disk full");
|
||||
}
|
||||
return realRenameSync(sourcePath, targetPath);
|
||||
});
|
||||
|
||||
await expect(
|
||||
maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
}),
|
||||
).rejects.toThrow("disk full");
|
||||
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
|
||||
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses fallback migration when multiple Matrix accounts need explicit selection", async () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
accountId: "ops",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||
const env = createMigrationEnv(stateDir);
|
||||
|
||||
await expect(
|
||||
maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
}),
|
||||
).rejects.toThrow(/defaultAccount is not set/i);
|
||||
expect(createBackupArchiveMock).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses fallback migration for a non-selected Matrix account", async () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "ops",
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
userId: "@default:example.org",
|
||||
accessToken: "default-token",
|
||||
env: {},
|
||||
});
|
||||
const legacyRoot = path.join(stateDir, "matrix");
|
||||
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}');
|
||||
const env = createMigrationEnv(stateDir);
|
||||
|
||||
await expect(
|
||||
maybeMigrateLegacyStorage({
|
||||
storagePaths,
|
||||
env,
|
||||
}),
|
||||
).rejects.toThrow(/targets account "ops"/i);
|
||||
expect(createBackupArchiveMock).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses an existing token-hash storage root after the access token changes", () => {
|
||||
const stateDir = setupStateDir();
|
||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-old",
|
||||
env: {},
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
|
||||
|
||||
const rotatedStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(rotatedStoragePaths.rootDir).toBe(oldStoragePaths.rootDir);
|
||||
expect(rotatedStoragePaths.tokenHash).toBe(oldStoragePaths.tokenHash);
|
||||
expect(rotatedStoragePaths.storagePath).toBe(oldStoragePaths.storagePath);
|
||||
});
|
||||
|
||||
it("reuses an existing token-hash storage root for the same device after the access token changes", () => {
|
||||
const stateDir = setupStateDir();
|
||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-old",
|
||||
deviceId: "DEVICE123",
|
||||
env: {},
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
|
||||
fs.writeFileSync(
|
||||
path.join(oldStoragePaths.rootDir, "storage-meta.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accountId: "default",
|
||||
accessTokenHash: oldStoragePaths.tokenHash,
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const rotatedStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
deviceId: "DEVICE123",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(rotatedStoragePaths.rootDir).toBe(oldStoragePaths.rootDir);
|
||||
expect(rotatedStoragePaths.tokenHash).toBe(oldStoragePaths.tokenHash);
|
||||
expect(rotatedStoragePaths.storagePath).toBe(oldStoragePaths.storagePath);
|
||||
});
|
||||
|
||||
it("prefers a populated older token-hash storage root over a newer empty root", () => {
|
||||
const stateDir = setupStateDir();
|
||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-old",
|
||||
env: {},
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
|
||||
|
||||
const newerCanonicalPaths = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
});
|
||||
fs.mkdirSync(newerCanonicalPaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(newerCanonicalPaths.rootDir, "storage-meta.json"),
|
||||
JSON.stringify({ accessTokenHash: newerCanonicalPaths.tokenHash }, null, 2),
|
||||
);
|
||||
|
||||
const resolvedPaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(resolvedPaths.rootDir).toBe(oldStoragePaths.rootDir);
|
||||
expect(resolvedPaths.tokenHash).toBe(oldStoragePaths.tokenHash);
|
||||
});
|
||||
|
||||
it("does not reuse a populated sibling storage root from a different device", () => {
|
||||
const stateDir = setupStateDir();
|
||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-old",
|
||||
deviceId: "OLDDEVICE",
|
||||
env: {},
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
|
||||
fs.writeFileSync(
|
||||
path.join(oldStoragePaths.rootDir, "startup-verification.json"),
|
||||
JSON.stringify({ deviceId: "OLDDEVICE" }, null, 2),
|
||||
);
|
||||
|
||||
const newerCanonicalPaths = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
});
|
||||
fs.mkdirSync(newerCanonicalPaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(newerCanonicalPaths.rootDir, "storage-meta.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accountId: "default",
|
||||
accessTokenHash: newerCanonicalPaths.tokenHash,
|
||||
deviceId: "NEWDEVICE",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const resolvedPaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
deviceId: "NEWDEVICE",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(resolvedPaths.rootDir).toBe(newerCanonicalPaths.rootDir);
|
||||
expect(resolvedPaths.tokenHash).toBe(newerCanonicalPaths.tokenHash);
|
||||
});
|
||||
|
||||
it("does not reuse a populated sibling storage root with ambiguous device metadata", () => {
|
||||
const stateDir = setupStateDir();
|
||||
const oldStoragePaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-old",
|
||||
env: {},
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(oldStoragePaths.storagePath, '{"legacy":true}');
|
||||
|
||||
const newerCanonicalPaths = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
});
|
||||
fs.mkdirSync(newerCanonicalPaths.rootDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(newerCanonicalPaths.rootDir, "storage-meta.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accountId: "default",
|
||||
accessTokenHash: newerCanonicalPaths.tokenHash,
|
||||
deviceId: "NEWDEVICE",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const resolvedPaths = resolveMatrixStoragePaths({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token-new",
|
||||
deviceId: "NEWDEVICE",
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(resolvedPaths.rootDir).toBe(newerCanonicalPaths.rootDir);
|
||||
expect(resolvedPaths.tokenHash).toBe(newerCanonicalPaths.tokenHash);
|
||||
});
|
||||
});
|
||||
@@ -1,46 +1,257 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { maybeCreateMatrixMigrationSnapshot, normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../../account-selection.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import {
|
||||
resolveMatrixAccountStorageRoot,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
} from "../../storage-paths.js";
|
||||
import type { MatrixStoragePaths } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_KEY = "default";
|
||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||
const THREAD_BINDINGS_FILENAME = "thread-bindings.json";
|
||||
const LEGACY_CRYPTO_MIGRATION_FILENAME = "legacy-crypto-migration.json";
|
||||
const RECOVERY_KEY_FILENAME = "recovery-key.json";
|
||||
const IDB_SNAPSHOT_FILENAME = "crypto-idb-snapshot.json";
|
||||
const STARTUP_VERIFICATION_FILENAME = "startup-verification.json";
|
||||
|
||||
function sanitizePathSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return cleaned || "unknown";
|
||||
}
|
||||
type LegacyMoveRecord = {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
type StoredRootMetadata = {
|
||||
homeserver?: string;
|
||||
userId?: string;
|
||||
accountId?: string;
|
||||
accessTokenHash?: string;
|
||||
deviceId?: string | null;
|
||||
};
|
||||
|
||||
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
} {
|
||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
const legacy = resolveMatrixLegacyFlatStoragePaths(stateDir);
|
||||
return { storagePath: legacy.storagePath, cryptoPath: legacy.cryptoPath };
|
||||
}
|
||||
|
||||
function assertLegacyMigrationAccountSelection(params: { accountKey: string }): void {
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return;
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
throw new Error(
|
||||
"Legacy Matrix client storage cannot be migrated automatically because multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.",
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAccountId = normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
const currentAccountId = normalizeAccountId(params.accountKey);
|
||||
if (selectedAccountId !== currentAccountId) {
|
||||
throw new Error(
|
||||
`Legacy Matrix client storage targets account "${selectedAccountId}", but the current client is starting account "${currentAccountId}". Start the selected account first so flat legacy storage is not migrated into the wrong account directory.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scoreStorageRoot(rootDir: string): number {
|
||||
let score = 0;
|
||||
if (fs.existsSync(path.join(rootDir, "bot-storage.json"))) {
|
||||
score += 8;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, "crypto"))) {
|
||||
score += 8;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, THREAD_BINDINGS_FILENAME))) {
|
||||
score += 4;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, LEGACY_CRYPTO_MIGRATION_FILENAME))) {
|
||||
score += 3;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, RECOVERY_KEY_FILENAME))) {
|
||||
score += 2;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, IDB_SNAPSHOT_FILENAME))) {
|
||||
score += 2;
|
||||
}
|
||||
if (fs.existsSync(path.join(rootDir, STORAGE_META_FILENAME))) {
|
||||
score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function resolveStorageRootMtimeMs(rootDir: string): number {
|
||||
try {
|
||||
return fs.statSync(rootDir).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredRootMetadata(rootDir: string): StoredRootMetadata {
|
||||
const metadata: StoredRootMetadata = {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, STORAGE_META_FILENAME), "utf8"),
|
||||
) as Partial<StoredRootMetadata>;
|
||||
if (typeof parsed.homeserver === "string" && parsed.homeserver.trim()) {
|
||||
metadata.homeserver = parsed.homeserver.trim();
|
||||
}
|
||||
if (typeof parsed.userId === "string" && parsed.userId.trim()) {
|
||||
metadata.userId = parsed.userId.trim();
|
||||
}
|
||||
if (typeof parsed.accountId === "string" && parsed.accountId.trim()) {
|
||||
metadata.accountId = parsed.accountId.trim();
|
||||
}
|
||||
if (typeof parsed.accessTokenHash === "string" && parsed.accessTokenHash.trim()) {
|
||||
metadata.accessTokenHash = parsed.accessTokenHash.trim();
|
||||
}
|
||||
if (typeof parsed.deviceId === "string" && parsed.deviceId.trim()) {
|
||||
metadata.deviceId = parsed.deviceId.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore missing or malformed storage metadata
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(rootDir, STARTUP_VERIFICATION_FILENAME), "utf8"),
|
||||
) as { deviceId?: unknown };
|
||||
if (!metadata.deviceId && typeof parsed.deviceId === "string" && parsed.deviceId.trim()) {
|
||||
metadata.deviceId = parsed.deviceId.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore missing or malformed verification state
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function isCompatibleStorageRoot(params: {
|
||||
candidateRootDir: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accountKey: string;
|
||||
deviceId?: string | null;
|
||||
requireExplicitDeviceMatch?: boolean;
|
||||
}): boolean {
|
||||
const metadata = readStoredRootMetadata(params.candidateRootDir);
|
||||
if (metadata.homeserver && metadata.homeserver !== params.homeserver) {
|
||||
return false;
|
||||
}
|
||||
if (metadata.userId && metadata.userId !== params.userId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
metadata.accountId &&
|
||||
normalizeAccountId(metadata.accountId) !== normalizeAccountId(params.accountKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.deviceId &&
|
||||
metadata.deviceId &&
|
||||
metadata.deviceId.trim() &&
|
||||
metadata.deviceId.trim() !== params.deviceId.trim()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.requireExplicitDeviceMatch &&
|
||||
params.deviceId &&
|
||||
(!metadata.deviceId || metadata.deviceId.trim() !== params.deviceId.trim())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolvePreferredMatrixStorageRoot(params: {
|
||||
canonicalRootDir: string;
|
||||
canonicalTokenHash: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accountKey: string;
|
||||
deviceId?: string | null;
|
||||
}): {
|
||||
rootDir: string;
|
||||
tokenHash: string;
|
||||
} {
|
||||
const parentDir = path.dirname(params.canonicalRootDir);
|
||||
const bestCurrentScore = scoreStorageRoot(params.canonicalRootDir);
|
||||
let best = {
|
||||
rootDir: params.canonicalRootDir,
|
||||
tokenHash: params.canonicalTokenHash,
|
||||
score: bestCurrentScore,
|
||||
mtimeMs: resolveStorageRootMtimeMs(params.canonicalRootDir),
|
||||
};
|
||||
|
||||
let siblingEntries: fs.Dirent[] = [];
|
||||
try {
|
||||
siblingEntries = fs.readdirSync(parentDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return {
|
||||
rootDir: best.rootDir,
|
||||
tokenHash: best.tokenHash,
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of siblingEntries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name === params.canonicalTokenHash) {
|
||||
continue;
|
||||
}
|
||||
const candidateRootDir = path.join(parentDir, entry.name);
|
||||
if (
|
||||
!isCompatibleStorageRoot({
|
||||
candidateRootDir,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accountKey: params.accountKey,
|
||||
deviceId: params.deviceId,
|
||||
// Once auth resolves a concrete device, only sibling roots that explicitly
|
||||
// declare that same device are safe to reuse across token rotations.
|
||||
requireExplicitDeviceMatch: Boolean(params.deviceId),
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const candidateScore = scoreStorageRoot(candidateRootDir);
|
||||
if (candidateScore <= 0) {
|
||||
continue;
|
||||
}
|
||||
const candidateMtimeMs = resolveStorageRootMtimeMs(candidateRootDir);
|
||||
if (
|
||||
candidateScore > best.score ||
|
||||
(best.rootDir !== params.canonicalRootDir &&
|
||||
candidateScore === best.score &&
|
||||
candidateMtimeMs > best.mtimeMs)
|
||||
) {
|
||||
best = {
|
||||
rootDir: candidateRootDir,
|
||||
tokenHash: entry.name,
|
||||
score: candidateScore,
|
||||
mtimeMs: candidateMtimeMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
||||
rootDir: best.rootDir,
|
||||
tokenHash: best.tokenHash,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,64 +260,152 @@ export function resolveMatrixStoragePaths(params: {
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
accountId?: string | null;
|
||||
deviceId?: string | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}): 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(
|
||||
const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
const canonical = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
accountKey,
|
||||
`${serverKey}__${userKey}`,
|
||||
tokenHash,
|
||||
);
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accessToken: params.accessToken,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const { rootDir, tokenHash } = resolvePreferredMatrixStorageRoot({
|
||||
canonicalRootDir: canonical.rootDir,
|
||||
canonicalTokenHash: canonical.tokenHash,
|
||||
homeserver: params.homeserver,
|
||||
userId: params.userId,
|
||||
accountKey: canonical.accountKey,
|
||||
deviceId: params.deviceId,
|
||||
});
|
||||
return {
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||
cryptoPath: path.join(rootDir, "crypto"),
|
||||
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
||||
accountKey,
|
||||
recoveryKeyPath: path.join(rootDir, "recovery-key.json"),
|
||||
idbSnapshotPath: path.join(rootDir, IDB_SNAPSHOT_FILENAME),
|
||||
accountKey: canonical.accountKey,
|
||||
tokenHash,
|
||||
};
|
||||
}
|
||||
|
||||
export function maybeMigrateLegacyStorage(params: {
|
||||
export async function maybeMigrateLegacyStorage(params: {
|
||||
storagePaths: MatrixStoragePaths;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
const legacy = resolveLegacyStoragePaths(params.env);
|
||||
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||
const hasNewStorage =
|
||||
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
|
||||
|
||||
if (!hasLegacyStorage && !hasLegacyCrypto) {
|
||||
return;
|
||||
}
|
||||
if (hasNewStorage) {
|
||||
const hasTargetStorage = fs.existsSync(params.storagePaths.storagePath);
|
||||
const hasTargetCrypto = fs.existsSync(params.storagePaths.cryptoPath);
|
||||
// Continue partial migrations one artifact at a time; only skip items whose targets already exist.
|
||||
const shouldMigrateStorage = hasLegacyStorage && !hasTargetStorage;
|
||||
const shouldMigrateCrypto = hasLegacyCrypto && !hasTargetCrypto;
|
||||
if (!shouldMigrateStorage && !shouldMigrateCrypto) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertLegacyMigrationAccountSelection({
|
||||
accountKey: params.storagePaths.accountKey,
|
||||
});
|
||||
|
||||
const logger = getMatrixRuntime().logging.getChildLogger({ module: "matrix-storage" });
|
||||
await maybeCreateMatrixMigrationSnapshot({
|
||||
trigger: "matrix-client-fallback",
|
||||
env: params.env,
|
||||
log: logger,
|
||||
});
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
if (hasLegacyStorage) {
|
||||
const moved: LegacyMoveRecord[] = [];
|
||||
const skippedExistingTargets: string[] = [];
|
||||
try {
|
||||
if (shouldMigrateStorage) {
|
||||
moveLegacyStoragePathOrThrow({
|
||||
sourcePath: legacy.storagePath,
|
||||
targetPath: params.storagePaths.storagePath,
|
||||
label: "sync store",
|
||||
moved,
|
||||
});
|
||||
} else if (hasLegacyStorage) {
|
||||
skippedExistingTargets.push(
|
||||
`- sync store remains at ${legacy.storagePath} because ${params.storagePaths.storagePath} already exists`,
|
||||
);
|
||||
}
|
||||
if (shouldMigrateCrypto) {
|
||||
moveLegacyStoragePathOrThrow({
|
||||
sourcePath: legacy.cryptoPath,
|
||||
targetPath: params.storagePaths.cryptoPath,
|
||||
label: "crypto store",
|
||||
moved,
|
||||
});
|
||||
} else if (hasLegacyCrypto) {
|
||||
skippedExistingTargets.push(
|
||||
`- crypto store remains at ${legacy.cryptoPath} because ${params.storagePaths.cryptoPath} already exists`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const rollbackError = rollbackLegacyMoves(moved);
|
||||
throw new Error(
|
||||
rollbackError
|
||||
? `Failed migrating legacy Matrix client storage: ${String(err)}. Rollback also failed: ${rollbackError}`
|
||||
: `Failed migrating legacy Matrix client storage: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
if (moved.length > 0) {
|
||||
logger.info(
|
||||
`matrix: migrated legacy client storage into ${params.storagePaths.rootDir}\n${moved
|
||||
.map((entry) => `- ${entry.label}: ${entry.sourcePath} -> ${entry.targetPath}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
if (skippedExistingTargets.length > 0) {
|
||||
logger.warn?.(
|
||||
`matrix: legacy client storage still exists in the flat path because some account-scoped targets already existed.\n${skippedExistingTargets.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function moveLegacyStoragePathOrThrow(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
label: string;
|
||||
moved: LegacyMoveRecord[];
|
||||
}): void {
|
||||
if (!fs.existsSync(params.sourcePath)) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(params.targetPath)) {
|
||||
throw new Error(
|
||||
`legacy Matrix ${params.label} target already exists (${params.targetPath}); refusing to overwrite it automatically`,
|
||||
);
|
||||
}
|
||||
fs.renameSync(params.sourcePath, params.targetPath);
|
||||
params.moved.push({
|
||||
sourcePath: params.sourcePath,
|
||||
targetPath: params.targetPath,
|
||||
label: params.label,
|
||||
});
|
||||
}
|
||||
|
||||
function rollbackLegacyMoves(moved: LegacyMoveRecord[]): string | null {
|
||||
for (const entry of moved.toReversed()) {
|
||||
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.
|
||||
if (!fs.existsSync(entry.targetPath) || fs.existsSync(entry.sourcePath)) {
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(entry.targetPath, entry.sourcePath);
|
||||
} catch (err) {
|
||||
return `${entry.label} (${entry.targetPath} -> ${entry.sourcePath}): ${String(err)}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeStorageMeta(params: {
|
||||
@@ -114,6 +413,7 @@ export function writeStorageMeta(params: {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accountId?: string | null;
|
||||
deviceId?: string | null;
|
||||
}): void {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -121,6 +421,7 @@ export function writeStorageMeta(params: {
|
||||
userId: params.userId,
|
||||
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
accessTokenHash: params.storagePaths.tokenHash,
|
||||
deviceId: params.deviceId ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken?: string;
|
||||
deviceId?: string;
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
@@ -11,14 +12,18 @@ export type MatrixResolvedConfig = {
|
||||
/**
|
||||
* 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.
|
||||
* Matrix storage reuses the most complete account-scoped root it can find for the
|
||||
* same homeserver/user/account tuple so token refreshes do not strand prior state.
|
||||
* If the device identity itself changes or crypto storage is lost, crypto state may
|
||||
* still need to be recreated together with the new access token.
|
||||
*/
|
||||
export type MatrixAuth = {
|
||||
accountId: string;
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
password?: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: number;
|
||||
encryption?: boolean;
|
||||
@@ -29,6 +34,8 @@ export type MatrixStoragePaths = {
|
||||
storagePath: string;
|
||||
cryptoPath: string;
|
||||
metaPath: string;
|
||||
recoveryKeyPath: string;
|
||||
idbSnapshotPath: string;
|
||||
accountKey: string;
|
||||
tokenHash: string;
|
||||
};
|
||||
|
||||
151
extensions/matrix/src/matrix/config-update.test.ts
Normal file
151
extensions/matrix/src/matrix/config-update.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveMatrixConfigFieldPath, updateMatrixAccountConfig } from "./config-update.js";
|
||||
|
||||
describe("updateMatrixAccountConfig", () => {
|
||||
it("resolves account-aware Matrix config field paths", () => {
|
||||
expect(resolveMatrixConfigFieldPath({} as CoreConfig, "default", "dm.policy")).toBe(
|
||||
"channels.matrix.dm.policy",
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveMatrixConfigFieldPath(cfg, "ops", ".dm.allowFrom")).toBe(
|
||||
"channels.matrix.accounts.ops.dm.allowFrom",
|
||||
);
|
||||
});
|
||||
|
||||
it("supports explicit null clears and boolean false values", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "old-token", // pragma: allowlist secret
|
||||
password: "old-password", // pragma: allowlist secret
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const updated = updateMatrixAccountConfig(cfg, "default", {
|
||||
accessToken: "new-token",
|
||||
password: null,
|
||||
userId: null,
|
||||
encryption: false,
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({
|
||||
accessToken: "new-token",
|
||||
encryption: false,
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.password).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.default?.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes account id and defaults account enabled=true", () => {
|
||||
const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", {
|
||||
name: "Main Bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.["main-bot"]).toMatchObject({
|
||||
name: "Main Bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates nested access config for named accounts without touching top-level defaults", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
dm: {
|
||||
policy: "pairing",
|
||||
},
|
||||
groups: {
|
||||
"!default:example.org": { allow: true },
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "pairing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const updated = updateMatrixAccountConfig(cfg, "ops", {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@alice:example.org"],
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!ops-room:example.org": { allow: true },
|
||||
},
|
||||
rooms: null,
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix"]?.dm?.policy).toBe("pairing");
|
||||
expect(updated.channels?.["matrix"]?.groups).toEqual({
|
||||
"!default:example.org": { allow: true },
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
|
||||
dm: {
|
||||
enabled: true,
|
||||
policy: "allowlist",
|
||||
allowFrom: ["@alice:example.org"],
|
||||
},
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!ops-room:example.org": { allow: true },
|
||||
},
|
||||
});
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses and canonicalizes non-normalized account entries when updating", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
Ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
const updated = updateMatrixAccountConfig(cfg, "ops", {
|
||||
deviceName: "Ops Bot",
|
||||
});
|
||||
|
||||
expect(updated.channels?.["matrix"]?.accounts?.Ops).toBeUndefined();
|
||||
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
deviceName: "Ops Bot",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
233
extensions/matrix/src/matrix/config-update.ts
Normal file
233
extensions/matrix/src/matrix/config-update.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import { findMatrixAccountConfig } from "./account-config.js";
|
||||
|
||||
export type MatrixAccountPatch = {
|
||||
name?: string | null;
|
||||
enabled?: boolean;
|
||||
homeserver?: string | null;
|
||||
userId?: string | null;
|
||||
accessToken?: string | null;
|
||||
password?: string | null;
|
||||
deviceId?: string | null;
|
||||
deviceName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
encryption?: boolean | null;
|
||||
initialSyncLimit?: number | null;
|
||||
dm?: MatrixConfig["dm"] | null;
|
||||
groupPolicy?: MatrixConfig["groupPolicy"] | null;
|
||||
groupAllowFrom?: MatrixConfig["groupAllowFrom"] | null;
|
||||
groups?: MatrixConfig["groups"] | null;
|
||||
rooms?: MatrixConfig["rooms"] | null;
|
||||
};
|
||||
|
||||
function applyNullableStringField(
|
||||
target: Record<string, unknown>,
|
||||
key: keyof MatrixAccountPatch,
|
||||
value: string | null | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
target[key] = trimmed;
|
||||
}
|
||||
|
||||
function cloneMatrixDmConfig(dm: MatrixConfig["dm"]): MatrixConfig["dm"] {
|
||||
if (!dm) {
|
||||
return dm;
|
||||
}
|
||||
return {
|
||||
...dm,
|
||||
...(dm.allowFrom ? { allowFrom: [...dm.allowFrom] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMatrixRoomMap(
|
||||
rooms: MatrixConfig["groups"] | MatrixConfig["rooms"],
|
||||
): MatrixConfig["groups"] | MatrixConfig["rooms"] {
|
||||
if (!rooms) {
|
||||
return rooms;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(rooms).map(([roomId, roomCfg]) => [roomId, roomCfg ? { ...roomCfg } : roomCfg]),
|
||||
);
|
||||
}
|
||||
|
||||
function applyNullableArrayField(
|
||||
target: Record<string, unknown>,
|
||||
key: keyof MatrixAccountPatch,
|
||||
value: Array<string | number> | null | undefined,
|
||||
): void {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
target[key] = [...value];
|
||||
}
|
||||
|
||||
export function shouldStoreMatrixAccountAtTopLevel(cfg: CoreConfig, accountId: string): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return false;
|
||||
}
|
||||
const accounts = cfg.channels?.matrix?.accounts;
|
||||
return !accounts || Object.keys(accounts).length === 0;
|
||||
}
|
||||
|
||||
export function resolveMatrixConfigPath(cfg: CoreConfig, accountId: string): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
|
||||
return "channels.matrix";
|
||||
}
|
||||
return `channels.matrix.accounts.${normalizedAccountId}`;
|
||||
}
|
||||
|
||||
export function resolveMatrixConfigFieldPath(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
fieldPath: string,
|
||||
): string {
|
||||
const suffix = fieldPath.trim().replace(/^\.+/, "");
|
||||
if (!suffix) {
|
||||
return resolveMatrixConfigPath(cfg, accountId);
|
||||
}
|
||||
return `${resolveMatrixConfigPath(cfg, accountId)}.${suffix}`;
|
||||
}
|
||||
|
||||
export function updateMatrixAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
patch: MatrixAccountPatch,
|
||||
): CoreConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const existingAccount = (findMatrixAccountConfig(cfg, normalizedAccountId) ??
|
||||
(normalizedAccountId === DEFAULT_ACCOUNT_ID ? matrix : {})) as MatrixConfig;
|
||||
const nextAccount: Record<string, unknown> = { ...existingAccount };
|
||||
|
||||
if (patch.name !== undefined) {
|
||||
if (patch.name === null) {
|
||||
delete nextAccount.name;
|
||||
} else {
|
||||
const trimmed = patch.name.trim();
|
||||
if (trimmed) {
|
||||
nextAccount.name = trimmed;
|
||||
} else {
|
||||
delete nextAccount.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof patch.enabled === "boolean") {
|
||||
nextAccount.enabled = patch.enabled;
|
||||
} else if (typeof nextAccount.enabled !== "boolean") {
|
||||
nextAccount.enabled = true;
|
||||
}
|
||||
|
||||
applyNullableStringField(nextAccount, "homeserver", patch.homeserver);
|
||||
applyNullableStringField(nextAccount, "userId", patch.userId);
|
||||
applyNullableStringField(nextAccount, "accessToken", patch.accessToken);
|
||||
applyNullableStringField(nextAccount, "password", patch.password);
|
||||
applyNullableStringField(nextAccount, "deviceId", patch.deviceId);
|
||||
applyNullableStringField(nextAccount, "deviceName", patch.deviceName);
|
||||
applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl);
|
||||
|
||||
if (patch.initialSyncLimit !== undefined) {
|
||||
if (patch.initialSyncLimit === null) {
|
||||
delete nextAccount.initialSyncLimit;
|
||||
} else {
|
||||
nextAccount.initialSyncLimit = Math.max(0, Math.floor(patch.initialSyncLimit));
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.encryption !== undefined) {
|
||||
if (patch.encryption === null) {
|
||||
delete nextAccount.encryption;
|
||||
} else {
|
||||
nextAccount.encryption = patch.encryption;
|
||||
}
|
||||
}
|
||||
if (patch.dm !== undefined) {
|
||||
if (patch.dm === null) {
|
||||
delete nextAccount.dm;
|
||||
} else {
|
||||
nextAccount.dm = cloneMatrixDmConfig({
|
||||
...((nextAccount.dm as MatrixConfig["dm"] | undefined) ?? {}),
|
||||
...patch.dm,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (patch.groupPolicy !== undefined) {
|
||||
if (patch.groupPolicy === null) {
|
||||
delete nextAccount.groupPolicy;
|
||||
} else {
|
||||
nextAccount.groupPolicy = patch.groupPolicy;
|
||||
}
|
||||
}
|
||||
applyNullableArrayField(nextAccount, "groupAllowFrom", patch.groupAllowFrom);
|
||||
if (patch.groups !== undefined) {
|
||||
if (patch.groups === null) {
|
||||
delete nextAccount.groups;
|
||||
} else {
|
||||
nextAccount.groups = cloneMatrixRoomMap(patch.groups);
|
||||
}
|
||||
}
|
||||
if (patch.rooms !== undefined) {
|
||||
if (patch.rooms === null) {
|
||||
delete nextAccount.rooms;
|
||||
} else {
|
||||
nextAccount.rooms = cloneMatrixRoomMap(patch.rooms);
|
||||
}
|
||||
}
|
||||
|
||||
const nextAccounts = Object.fromEntries(
|
||||
Object.entries(matrix.accounts ?? {}).filter(
|
||||
([rawAccountId]) =>
|
||||
rawAccountId === normalizedAccountId ||
|
||||
normalizeAccountId(rawAccountId) !== normalizedAccountId,
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
|
||||
const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...baseMatrix,
|
||||
...(defaultAccount ? { defaultAccount } : {}),
|
||||
enabled: true,
|
||||
...nextAccount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
matrix: {
|
||||
...matrix,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...nextAccounts,
|
||||
[normalizedAccountId]: nextAccount as MatrixConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,73 +1,214 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMatrixRuntime, setMatrixRuntime } from "../runtime.js";
|
||||
import { loadMatrixCredentials, resolveMatrixCredentialsDir } from "./credentials.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
credentialsMatchConfig,
|
||||
loadMatrixCredentials,
|
||||
clearMatrixCredentials,
|
||||
resolveMatrixCredentialsPath,
|
||||
saveMatrixCredentials,
|
||||
touchMatrixCredentials,
|
||||
} from "./credentials.js";
|
||||
|
||||
describe("matrix credentials paths", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
clearMatrixRuntime();
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
});
|
||||
describe("matrix credentials storage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
clearMatrixRuntime();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to OPENCLAW_STATE_DIR when runtime is not initialized", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
|
||||
expect(resolveMatrixCredentialsDir(process.env)).toBe(
|
||||
path.join(stateDir, "credentials", "matrix"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers runtime state dir when runtime is initialized", () => {
|
||||
const runtimeStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-runtime-"));
|
||||
const envStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-env-"));
|
||||
process.env.OPENCLAW_STATE_DIR = envStateDir;
|
||||
|
||||
function setupStateDir(
|
||||
cfg: Record<string, unknown> = {
|
||||
channels: {
|
||||
matrix: {},
|
||||
},
|
||||
},
|
||||
): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-"));
|
||||
tempDirs.push(dir);
|
||||
setMatrixRuntime({
|
||||
config: {
|
||||
loadConfig: () => cfg,
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: () => runtimeStateDir,
|
||||
resolveStateDir: () => dir,
|
||||
},
|
||||
} as never);
|
||||
return dir;
|
||||
}
|
||||
|
||||
expect(resolveMatrixCredentialsDir(process.env)).toBe(
|
||||
path.join(runtimeStateDir, "credentials", "matrix"),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit stateDir argument over runtime/env", () => {
|
||||
const explicitStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-explicit-"));
|
||||
const runtimeStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-runtime-"));
|
||||
process.env.OPENCLAW_STATE_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-env-"));
|
||||
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => runtimeStateDir,
|
||||
it("writes credentials atomically with secure file permissions", async () => {
|
||||
const stateDir = setupStateDir();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
deviceId: "DEVICE123",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(resolveMatrixCredentialsDir(process.env, explicitStateDir)).toBe(
|
||||
path.join(explicitStateDir, "credentials", "matrix"),
|
||||
{},
|
||||
"ops",
|
||||
);
|
||||
|
||||
const credPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
expect(fs.existsSync(credPath)).toBe(true);
|
||||
expect(credPath).toBe(path.join(stateDir, "credentials", "matrix", "credentials-ops.json"));
|
||||
const mode = fs.statSync(credPath).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it("returns null without throwing when credentials are missing and runtime is absent", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-missing-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
it("touch updates lastUsedAt while preserving createdAt", async () => {
|
||||
setupStateDir();
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z"));
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "secret-token",
|
||||
},
|
||||
{},
|
||||
"default",
|
||||
);
|
||||
const initial = loadMatrixCredentials({}, "default");
|
||||
expect(initial).not.toBeNull();
|
||||
|
||||
expect(() => loadMatrixCredentials(process.env)).not.toThrow();
|
||||
expect(loadMatrixCredentials(process.env)).toBeNull();
|
||||
vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z"));
|
||||
await touchMatrixCredentials({}, "default");
|
||||
const touched = loadMatrixCredentials({}, "default");
|
||||
expect(touched).not.toBeNull();
|
||||
|
||||
expect(touched?.createdAt).toBe(initial?.createdAt);
|
||||
expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates legacy matrix credential files on read", async () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
|
||||
const currentPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "legacy-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = loadMatrixCredentials({}, "ops");
|
||||
|
||||
expect(loaded?.accessToken).toBe("legacy-token");
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
expect(fs.existsSync(currentPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not migrate legacy default credentials during a non-selected account read", () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "default",
|
||||
accounts: {
|
||||
default: {
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
accessToken: "default-token",
|
||||
},
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
|
||||
const currentPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
homeserver: "https://matrix.default.example.org",
|
||||
userId: "@default:example.org",
|
||||
accessToken: "default-token",
|
||||
createdAt: "2026-03-01T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const loaded = loadMatrixCredentials({}, "ops");
|
||||
|
||||
expect(loaded).toBeNull();
|
||||
expect(fs.existsSync(legacyPath)).toBe(true);
|
||||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("clears both current and legacy credential paths", () => {
|
||||
const stateDir = setupStateDir({
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const currentPath = resolveMatrixCredentialsPath({}, "ops");
|
||||
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
|
||||
fs.mkdirSync(path.dirname(currentPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
||||
fs.writeFileSync(currentPath, "{}");
|
||||
fs.writeFileSync(legacyPath, "{}");
|
||||
|
||||
clearMatrixCredentials({}, "ops");
|
||||
|
||||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
expect(fs.existsSync(legacyPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a token match when userId is absent", () => {
|
||||
expect(
|
||||
credentialsMatchConfig(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@old:example.org",
|
||||
accessToken: "tok-old",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "",
|
||||
accessToken: "tok-new",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
credentialsMatchConfig(
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,16 @@ 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 { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { tryGetMatrixRuntime } from "../runtime.js";
|
||||
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
} from "../storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -14,32 +22,64 @@ export type MatrixStoredCredentials = {
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function credentialsFilename(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return "credentials.json";
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
// 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`;
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
|
||||
}
|
||||
|
||||
function resolveLegacyMigrationSourcePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId?: string | null,
|
||||
): string | null {
|
||||
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
|
||||
return null;
|
||||
}
|
||||
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
|
||||
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
|
||||
}
|
||||
|
||||
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
|
||||
const raw = fs.readFileSync(filePath, "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;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const runtime = tryGetMatrixRuntime();
|
||||
const resolvedStateDir =
|
||||
stateDir ?? runtime?.state.resolveStateDir(env, os.homedir) ?? resolveStateDir(env, os.homedir);
|
||||
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||
const resolvedStateDir = stateDir ?? resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
return path.join(dir, credentialsFilename(accountId));
|
||||
const resolvedStateDir = resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
@@ -48,32 +88,38 @@ export function loadMatrixCredentials(
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (!fs.existsSync(credPath)) {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
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"
|
||||
) {
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveMatrixCredentials(
|
||||
export async function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const dir = resolveMatrixCredentialsDir(env);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
): Promise<void> {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
@@ -85,13 +131,13 @@ export function saveMatrixCredentials(
|
||||
lastUsedAt: now,
|
||||
};
|
||||
|
||||
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
await writeJsonFileAtomically(credPath, toSave);
|
||||
}
|
||||
|
||||
export function touchMatrixCredentials(
|
||||
export async function touchMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const existing = loadMatrixCredentials(env, accountId);
|
||||
if (!existing) {
|
||||
return;
|
||||
@@ -99,30 +145,40 @@ export function touchMatrixCredentials(
|
||||
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||
await writeJsonFileAtomically(credPath, existing);
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
fs.unlinkSync(credPath);
|
||||
const paths = [
|
||||
resolveMatrixCredentialsPath(env, accountId),
|
||||
resolveLegacyMigrationSourcePath(env, accountId),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string },
|
||||
config: { homeserver: string; userId: string; accessToken?: string },
|
||||
): boolean {
|
||||
// If userId is empty (token-based auth), only match homeserver
|
||||
if (!config.userId) {
|
||||
return stored.homeserver === config.homeserver;
|
||||
if (!config.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
it("rethrows non-crypto module errors without bootstrapping", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const requireFn = vi.fn(() => {
|
||||
throw new Error("Cannot find module '@vector-im/matrix-bot-sdk'");
|
||||
throw new Error("Cannot find module 'not-the-matrix-crypto-runtime'");
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -66,7 +66,7 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
}),
|
||||
).rejects.toThrow("Cannot find module '@vector-im/matrix-bot-sdk'");
|
||||
).rejects.toThrow("Cannot find module 'not-the-matrix-crypto-runtime'");
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,40 +1,43 @@
|
||||
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 { runPluginCommandWithTimeout, type RuntimeEnv } from "../../runtime-api.js";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
|
||||
const MATRIX_CRYPTO_DOWNLOAD_HELPER = "@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js";
|
||||
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
|
||||
|
||||
function formatCommandError(result: { stderr: string; stdout: string }): string {
|
||||
const stderr = result.stderr.trim();
|
||||
if (stderr) {
|
||||
return stderr;
|
||||
type MatrixCryptoRuntimeDeps = {
|
||||
requireFn?: (id: string) => unknown;
|
||||
runCommand?: (params: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) => Promise<CommandResult>;
|
||||
resolveFn?: (id: string) => string;
|
||||
nodeExecutable?: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
const stdout = result.stdout.trim();
|
||||
if (stdout) {
|
||||
return stdout;
|
||||
}
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
function isMissingMatrixCryptoRuntimeError(err: unknown): boolean {
|
||||
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||
return (
|
||||
message.includes("Cannot find module") &&
|
||||
message.includes("@matrix-org/matrix-sdk-crypto-nodejs-")
|
||||
);
|
||||
}
|
||||
|
||||
export function isMatrixSdkAvailable(): boolean {
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
req.resolve(MATRIX_SDK_PACKAGE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return resolveMissingMatrixPackages().length === 0;
|
||||
}
|
||||
|
||||
function resolvePluginRoot(): string {
|
||||
@@ -42,23 +45,108 @@ function resolvePluginRoot(): string {
|
||||
return path.resolve(currentDir, "..", "..");
|
||||
}
|
||||
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: {
|
||||
log?: (message: string) => void;
|
||||
requireFn?: (id: string) => unknown;
|
||||
resolveFn?: (id: string) => string;
|
||||
runCommand?: typeof runPluginCommandWithTimeout;
|
||||
nodeExecutable?: string;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const req = createRequire(import.meta.url);
|
||||
const requireFn = params.requireFn ?? ((id: string) => req(id));
|
||||
const resolveFn = params.resolveFn ?? ((id: string) => req.resolve(id));
|
||||
const runCommand = params.runCommand ?? runPluginCommandWithTimeout;
|
||||
const nodeExecutable = params.nodeExecutable ?? process.execPath;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultRequireFn(id: string): unknown {
|
||||
return createRequire(import.meta.url)(id);
|
||||
}
|
||||
|
||||
function defaultResolveFn(id: string): string {
|
||||
return createRequire(import.meta.url).resolve(id);
|
||||
}
|
||||
|
||||
function isMissingMatrixCryptoRuntimeError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes("@matrix-org/matrix-sdk-crypto-nodejs-") ||
|
||||
message.includes("matrix-sdk-crypto-nodejs") ||
|
||||
message.includes("download-lib.js")
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: MatrixCryptoRuntimeDeps = {},
|
||||
): Promise<void> {
|
||||
const requireFn = params.requireFn ?? defaultRequireFn;
|
||||
try {
|
||||
requireFn(MATRIX_SDK_PACKAGE);
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
return;
|
||||
} catch (err) {
|
||||
if (!isMissingMatrixCryptoRuntimeError(err)) {
|
||||
@@ -66,8 +154,11 @@ export async function ensureMatrixCryptoRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
const scriptPath = resolveFn(MATRIX_CRYPTO_DOWNLOAD_HELPER);
|
||||
params.log?.("matrix: crypto runtime missing; downloading platform library…");
|
||||
const resolveFn = params.resolveFn ?? defaultResolveFn;
|
||||
const scriptPath = resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js");
|
||||
params.log?.("matrix: bootstrapping native crypto runtime");
|
||||
const runCommand = params.runCommand ?? runFixedCommandWithTimeout;
|
||||
const nodeExecutable = params.nodeExecutable ?? process.execPath;
|
||||
const result = await runCommand({
|
||||
argv: [nodeExecutable, scriptPath],
|
||||
cwd: path.dirname(scriptPath),
|
||||
@@ -75,16 +166,12 @@ export async function ensureMatrixCryptoRuntime(
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Matrix crypto runtime bootstrap failed: ${formatCommandError(result)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
requireFn(MATRIX_SDK_PACKAGE);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Matrix crypto runtime remains unavailable after bootstrap: ${err instanceof Error ? err.message : String(err)}`,
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix crypto runtime bootstrap failed.",
|
||||
);
|
||||
}
|
||||
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
}
|
||||
|
||||
export async function ensureMatrixSdkInstalled(params: {
|
||||
@@ -96,9 +183,13 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
}
|
||||
const confirm = params.confirm;
|
||||
if (confirm) {
|
||||
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
||||
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 @vector-im/matrix-bot-sdk (install dependencies first).");
|
||||
throw new Error(
|
||||
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +198,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await runPluginCommandWithTimeout({
|
||||
const result = await runFixedCommandWithTimeout({
|
||||
argv: command,
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
@@ -119,8 +210,11 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
);
|
||||
}
|
||||
if (!isMatrixSdkAvailable()) {
|
||||
const missing = resolveMissingMatrixPackages();
|
||||
throw new Error(
|
||||
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
|
||||
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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
45
extensions/matrix/src/matrix/device-health.test.ts
Normal file
45
extensions/matrix/src/matrix/device-health.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOpenClawManagedMatrixDevice, summarizeMatrixDeviceHealth } from "./device-health.js";
|
||||
|
||||
describe("matrix device health", () => {
|
||||
it("detects OpenClaw-managed device names", () => {
|
||||
expect(isOpenClawManagedMatrixDevice("OpenClaw Gateway")).toBe(true);
|
||||
expect(isOpenClawManagedMatrixDevice("OpenClaw Debug")).toBe(true);
|
||||
expect(isOpenClawManagedMatrixDevice("Element iPhone")).toBe(false);
|
||||
expect(isOpenClawManagedMatrixDevice(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("summarizes stale OpenClaw-managed devices separately from the current device", () => {
|
||||
const summary = summarizeMatrixDeviceHealth([
|
||||
{
|
||||
deviceId: "du314Zpw3A",
|
||||
displayName: "OpenClaw Gateway",
|
||||
current: true,
|
||||
},
|
||||
{
|
||||
deviceId: "BritdXC6iL",
|
||||
displayName: "OpenClaw Gateway",
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "G6NJU9cTgs",
|
||||
displayName: "OpenClaw Debug",
|
||||
current: false,
|
||||
},
|
||||
{
|
||||
deviceId: "phone123",
|
||||
displayName: "Element iPhone",
|
||||
current: false,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary.currentDeviceId).toBe("du314Zpw3A");
|
||||
expect(summary.currentOpenClawDevices).toEqual([
|
||||
expect.objectContaining({ deviceId: "du314Zpw3A" }),
|
||||
]);
|
||||
expect(summary.staleOpenClawDevices).toEqual([
|
||||
expect.objectContaining({ deviceId: "BritdXC6iL" }),
|
||||
expect.objectContaining({ deviceId: "G6NJU9cTgs" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
31
extensions/matrix/src/matrix/device-health.ts
Normal file
31
extensions/matrix/src/matrix/device-health.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type MatrixManagedDeviceInfo = {
|
||||
deviceId: string;
|
||||
displayName: string | null;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
export type MatrixDeviceHealthSummary = {
|
||||
currentDeviceId: string | null;
|
||||
staleOpenClawDevices: MatrixManagedDeviceInfo[];
|
||||
currentOpenClawDevices: MatrixManagedDeviceInfo[];
|
||||
};
|
||||
|
||||
const OPENCLAW_DEVICE_NAME_PREFIX = "OpenClaw ";
|
||||
|
||||
export function isOpenClawManagedMatrixDevice(displayName: string | null | undefined): boolean {
|
||||
return displayName?.startsWith(OPENCLAW_DEVICE_NAME_PREFIX) === true;
|
||||
}
|
||||
|
||||
export function summarizeMatrixDeviceHealth(
|
||||
devices: MatrixManagedDeviceInfo[],
|
||||
): MatrixDeviceHealthSummary {
|
||||
const currentDeviceId = devices.find((device) => device.current)?.deviceId ?? null;
|
||||
const openClawDevices = devices.filter((device) =>
|
||||
isOpenClawManagedMatrixDevice(device.displayName),
|
||||
);
|
||||
return {
|
||||
currentDeviceId,
|
||||
staleOpenClawDevices: openClawDevices.filter((device) => !device.current),
|
||||
currentOpenClawDevices: openClawDevices.filter((device) => device.current),
|
||||
};
|
||||
}
|
||||
139
extensions/matrix/src/matrix/direct-management.test.ts
Normal file
139
extensions/matrix/src/matrix/direct-management.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { inspectMatrixDirectRooms, repairMatrixDirectRooms } from "./direct-management.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { EventType } from "./send/types.js";
|
||||
|
||||
function createClient(overrides: Partial<MatrixClient> = {}): MatrixClient {
|
||||
return {
|
||||
getUserId: vi.fn(async () => "@bot:example.org"),
|
||||
getAccountData: vi.fn(async () => undefined),
|
||||
getJoinedRooms: vi.fn(async () => [] as string[]),
|
||||
getJoinedRoomMembers: vi.fn(async () => [] as string[]),
|
||||
setAccountData: vi.fn(async () => undefined),
|
||||
createDirectRoom: vi.fn(async () => "!created:example.org"),
|
||||
...overrides,
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
describe("inspectMatrixDirectRooms", () => {
|
||||
it("prefers strict mapped rooms over discovered rooms", async () => {
|
||||
const client = createClient({
|
||||
getAccountData: vi.fn(async () => ({
|
||||
"@alice:example.org": ["!dm:example.org", "!shared:example.org"],
|
||||
})),
|
||||
getJoinedRooms: vi.fn(async () => ["!dm:example.org", "!shared:example.org"]),
|
||||
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
|
||||
roomId === "!dm:example.org"
|
||||
? ["@bot:example.org", "@alice:example.org"]
|
||||
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
|
||||
),
|
||||
});
|
||||
|
||||
const result = await inspectMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result.activeRoomId).toBe("!dm:example.org");
|
||||
expect(result.mappedRooms).toEqual([
|
||||
expect.objectContaining({ roomId: "!dm:example.org", strict: true }),
|
||||
expect.objectContaining({ roomId: "!shared:example.org", strict: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to discovered strict joined rooms when m.direct is stale", async () => {
|
||||
const client = createClient({
|
||||
getAccountData: vi.fn(async () => ({
|
||||
"@alice:example.org": ["!stale:example.org"],
|
||||
})),
|
||||
getJoinedRooms: vi.fn(async () => ["!stale:example.org", "!fresh:example.org"]),
|
||||
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
|
||||
roomId === "!fresh:example.org"
|
||||
? ["@bot:example.org", "@alice:example.org"]
|
||||
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
|
||||
),
|
||||
});
|
||||
|
||||
const result = await inspectMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result.activeRoomId).toBe("!fresh:example.org");
|
||||
expect(result.discoveredStrictRoomIds).toEqual(["!fresh:example.org"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairMatrixDirectRooms", () => {
|
||||
it("repoints m.direct to an existing strict joined room", async () => {
|
||||
const setAccountData = vi.fn(async () => undefined);
|
||||
const client = createClient({
|
||||
getAccountData: vi.fn(async () => ({
|
||||
"@alice:example.org": ["!stale:example.org"],
|
||||
})),
|
||||
getJoinedRooms: vi.fn(async () => ["!stale:example.org", "!fresh:example.org"]),
|
||||
getJoinedRoomMembers: vi.fn(async (roomId: string) =>
|
||||
roomId === "!fresh:example.org"
|
||||
? ["@bot:example.org", "@alice:example.org"]
|
||||
: ["@bot:example.org", "@alice:example.org", "@mallory:example.org"],
|
||||
),
|
||||
setAccountData,
|
||||
});
|
||||
|
||||
const result = await repairMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: "@alice:example.org",
|
||||
encrypted: true,
|
||||
});
|
||||
|
||||
expect(result.activeRoomId).toBe("!fresh:example.org");
|
||||
expect(result.createdRoomId).toBeNull();
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({
|
||||
"@alice:example.org": ["!fresh:example.org", "!stale:example.org"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a fresh direct room when no healthy DM exists", async () => {
|
||||
const createDirectRoom = vi.fn(async () => "!created:example.org");
|
||||
const setAccountData = vi.fn(async () => undefined);
|
||||
const client = createClient({
|
||||
getJoinedRooms: vi.fn(async () => ["!shared:example.org"]),
|
||||
getJoinedRoomMembers: vi.fn(async () => [
|
||||
"@bot:example.org",
|
||||
"@alice:example.org",
|
||||
"@mallory:example.org",
|
||||
]),
|
||||
createDirectRoom,
|
||||
setAccountData,
|
||||
});
|
||||
|
||||
const result = await repairMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: "@alice:example.org",
|
||||
encrypted: true,
|
||||
});
|
||||
|
||||
expect(createDirectRoom).toHaveBeenCalledWith("@alice:example.org", { encrypted: true });
|
||||
expect(result.createdRoomId).toBe("!created:example.org");
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({
|
||||
"@alice:example.org": ["!created:example.org"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unqualified Matrix user ids", async () => {
|
||||
const client = createClient();
|
||||
|
||||
await expect(
|
||||
repairMatrixDirectRooms({
|
||||
client,
|
||||
remoteUserId: "alice",
|
||||
}),
|
||||
).rejects.toThrow('Matrix user IDs must be fully qualified (got "alice")');
|
||||
});
|
||||
});
|
||||
237
extensions/matrix/src/matrix/direct-management.ts
Normal file
237
extensions/matrix/src/matrix/direct-management.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
isStrictDirectMembership,
|
||||
isStrictDirectRoom,
|
||||
readJoinedMatrixMembers,
|
||||
} from "./direct-room.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { EventType, type MatrixDirectAccountData } from "./send/types.js";
|
||||
import { isMatrixQualifiedUserId } from "./target-ids.js";
|
||||
|
||||
export type MatrixDirectRoomCandidate = {
|
||||
roomId: string;
|
||||
joinedMembers: string[] | null;
|
||||
strict: boolean;
|
||||
source: "account-data" | "joined";
|
||||
};
|
||||
|
||||
export type MatrixDirectRoomInspection = {
|
||||
selfUserId: string | null;
|
||||
remoteUserId: string;
|
||||
mappedRoomIds: string[];
|
||||
mappedRooms: MatrixDirectRoomCandidate[];
|
||||
discoveredStrictRoomIds: string[];
|
||||
activeRoomId: string | null;
|
||||
};
|
||||
|
||||
export type MatrixDirectRoomRepairResult = MatrixDirectRoomInspection & {
|
||||
createdRoomId: string | null;
|
||||
changed: boolean;
|
||||
directContentBefore: MatrixDirectAccountData;
|
||||
directContentAfter: MatrixDirectAccountData;
|
||||
};
|
||||
|
||||
async function readMatrixDirectAccountData(client: MatrixClient): Promise<MatrixDirectAccountData> {
|
||||
try {
|
||||
const direct = (await client.getAccountData(EventType.Direct)) as MatrixDirectAccountData;
|
||||
return direct && typeof direct === "object" && !Array.isArray(direct) ? direct : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRemoteUserId(remoteUserId: string): string {
|
||||
const normalized = remoteUserId.trim();
|
||||
if (!isMatrixQualifiedUserId(normalized)) {
|
||||
throw new Error(`Matrix user IDs must be fully qualified (got "${remoteUserId}")`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMappedRoomIds(direct: MatrixDirectAccountData, remoteUserId: string): string[] {
|
||||
const current = direct[remoteUserId];
|
||||
if (!Array.isArray(current)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const value of current) {
|
||||
const roomId = typeof value === "string" ? value.trim() : "";
|
||||
if (!roomId || seen.has(roomId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(roomId);
|
||||
normalized.push(roomId);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRoomIdList(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const value of values) {
|
||||
const roomId = value.trim();
|
||||
if (!roomId || seen.has(roomId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(roomId);
|
||||
normalized.push(roomId);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function classifyDirectRoomCandidate(params: {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
remoteUserId: string;
|
||||
selfUserId: string | null;
|
||||
source: "account-data" | "joined";
|
||||
}): Promise<MatrixDirectRoomCandidate> {
|
||||
const joinedMembers = await readJoinedMatrixMembers(params.client, params.roomId);
|
||||
return {
|
||||
roomId: params.roomId,
|
||||
joinedMembers,
|
||||
strict:
|
||||
joinedMembers !== null &&
|
||||
isStrictDirectMembership({
|
||||
selfUserId: params.selfUserId,
|
||||
remoteUserId: params.remoteUserId,
|
||||
joinedMembers,
|
||||
}),
|
||||
source: params.source,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNextDirectContent(params: {
|
||||
directContent: MatrixDirectAccountData;
|
||||
remoteUserId: string;
|
||||
roomId: string;
|
||||
}): MatrixDirectAccountData {
|
||||
const current = normalizeMappedRoomIds(params.directContent, params.remoteUserId);
|
||||
const nextRooms = normalizeRoomIdList([params.roomId, ...current]);
|
||||
return {
|
||||
...params.directContent,
|
||||
[params.remoteUserId]: nextRooms,
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistMatrixDirectRoomMapping(params: {
|
||||
client: MatrixClient;
|
||||
remoteUserId: string;
|
||||
roomId: string;
|
||||
}): Promise<boolean> {
|
||||
const remoteUserId = normalizeRemoteUserId(params.remoteUserId);
|
||||
const directContent = await readMatrixDirectAccountData(params.client);
|
||||
const current = normalizeMappedRoomIds(directContent, remoteUserId);
|
||||
if (current[0] === params.roomId) {
|
||||
return false;
|
||||
}
|
||||
await params.client.setAccountData(
|
||||
EventType.Direct,
|
||||
buildNextDirectContent({
|
||||
directContent,
|
||||
remoteUserId,
|
||||
roomId: params.roomId,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function inspectMatrixDirectRooms(params: {
|
||||
client: MatrixClient;
|
||||
remoteUserId: string;
|
||||
}): Promise<MatrixDirectRoomInspection> {
|
||||
const remoteUserId = normalizeRemoteUserId(params.remoteUserId);
|
||||
const selfUserId = (await params.client.getUserId().catch(() => null))?.trim() || null;
|
||||
const directContent = await readMatrixDirectAccountData(params.client);
|
||||
const mappedRoomIds = normalizeMappedRoomIds(directContent, remoteUserId);
|
||||
const mappedRooms = await Promise.all(
|
||||
mappedRoomIds.map(
|
||||
async (roomId) =>
|
||||
await classifyDirectRoomCandidate({
|
||||
client: params.client,
|
||||
roomId,
|
||||
remoteUserId,
|
||||
selfUserId,
|
||||
source: "account-data",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const mappedStrict = mappedRooms.find((room) => room.strict);
|
||||
|
||||
let joinedRooms: string[] = [];
|
||||
if (!mappedStrict && typeof params.client.getJoinedRooms === "function") {
|
||||
try {
|
||||
const resolved = await params.client.getJoinedRooms();
|
||||
joinedRooms = Array.isArray(resolved) ? resolved : [];
|
||||
} catch {
|
||||
joinedRooms = [];
|
||||
}
|
||||
}
|
||||
const discoveredStrictRoomIds: string[] = [];
|
||||
for (const roomId of normalizeRoomIdList(joinedRooms)) {
|
||||
if (mappedRoomIds.includes(roomId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await isStrictDirectRoom({
|
||||
client: params.client,
|
||||
roomId,
|
||||
remoteUserId,
|
||||
selfUserId,
|
||||
})
|
||||
) {
|
||||
discoveredStrictRoomIds.push(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selfUserId,
|
||||
remoteUserId,
|
||||
mappedRoomIds,
|
||||
mappedRooms,
|
||||
discoveredStrictRoomIds,
|
||||
activeRoomId: mappedStrict?.roomId ?? discoveredStrictRoomIds[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repairMatrixDirectRooms(params: {
|
||||
client: MatrixClient;
|
||||
remoteUserId: string;
|
||||
encrypted?: boolean;
|
||||
}): Promise<MatrixDirectRoomRepairResult> {
|
||||
const remoteUserId = normalizeRemoteUserId(params.remoteUserId);
|
||||
const directContentBefore = await readMatrixDirectAccountData(params.client);
|
||||
const inspected = await inspectMatrixDirectRooms({
|
||||
client: params.client,
|
||||
remoteUserId,
|
||||
});
|
||||
const activeRoomId =
|
||||
inspected.activeRoomId ??
|
||||
(await params.client.createDirectRoom(remoteUserId, {
|
||||
encrypted: params.encrypted === true,
|
||||
}));
|
||||
const createdRoomId = inspected.activeRoomId ? null : activeRoomId;
|
||||
const directContentAfter = buildNextDirectContent({
|
||||
directContent: directContentBefore,
|
||||
remoteUserId,
|
||||
roomId: activeRoomId,
|
||||
});
|
||||
const changed =
|
||||
JSON.stringify(directContentAfter[remoteUserId] ?? []) !==
|
||||
JSON.stringify(directContentBefore[remoteUserId] ?? []);
|
||||
if (changed) {
|
||||
await persistMatrixDirectRoomMapping({
|
||||
client: params.client,
|
||||
remoteUserId,
|
||||
roomId: activeRoomId,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...inspected,
|
||||
activeRoomId,
|
||||
createdRoomId,
|
||||
changed,
|
||||
directContentBefore,
|
||||
directContentAfter,
|
||||
};
|
||||
}
|
||||
66
extensions/matrix/src/matrix/direct-room.ts
Normal file
66
extensions/matrix/src/matrix/direct-room.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
function trimMaybeString(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeJoinedMatrixMembers(joinedMembers: unknown): string[] {
|
||||
if (!Array.isArray(joinedMembers)) {
|
||||
return [];
|
||||
}
|
||||
return joinedMembers
|
||||
.map((entry) => trimMaybeString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
}
|
||||
|
||||
export function isStrictDirectMembership(params: {
|
||||
selfUserId?: string | null;
|
||||
remoteUserId?: string | null;
|
||||
joinedMembers?: readonly string[] | null;
|
||||
}): boolean {
|
||||
const selfUserId = trimMaybeString(params.selfUserId);
|
||||
const remoteUserId = trimMaybeString(params.remoteUserId);
|
||||
const joinedMembers = params.joinedMembers ?? [];
|
||||
return Boolean(
|
||||
selfUserId &&
|
||||
remoteUserId &&
|
||||
joinedMembers.length === 2 &&
|
||||
joinedMembers.includes(selfUserId) &&
|
||||
joinedMembers.includes(remoteUserId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function readJoinedMatrixMembers(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
return normalizeJoinedMatrixMembers(await client.getJoinedRoomMembers(roomId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isStrictDirectRoom(params: {
|
||||
client: MatrixClient;
|
||||
roomId: string;
|
||||
remoteUserId: string;
|
||||
selfUserId?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const selfUserId =
|
||||
trimMaybeString(params.selfUserId) ??
|
||||
trimMaybeString(await params.client.getUserId().catch(() => null));
|
||||
if (!selfUserId) {
|
||||
return false;
|
||||
}
|
||||
const joinedMembers = await readJoinedMatrixMembers(params.client, params.roomId);
|
||||
return isStrictDirectMembership({
|
||||
selfUserId,
|
||||
remoteUserId: params.remoteUserId,
|
||||
joinedMembers,
|
||||
});
|
||||
}
|
||||
27
extensions/matrix/src/matrix/encryption-guidance.ts
Normal file
27
extensions/matrix/src/matrix/encryption-guidance.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { resolveDefaultMatrixAccountId } from "./accounts.js";
|
||||
import { resolveMatrixConfigFieldPath } from "./config-update.js";
|
||||
|
||||
export function resolveMatrixEncryptionConfigPath(
|
||||
cfg: CoreConfig,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const effectiveAccountId =
|
||||
normalizeOptionalAccountId(accountId) ?? resolveDefaultMatrixAccountId(cfg);
|
||||
return resolveMatrixConfigFieldPath(cfg, effectiveAccountId, "encryption");
|
||||
}
|
||||
|
||||
export function formatMatrixEncryptionUnavailableError(
|
||||
cfg: CoreConfig,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
return `Matrix encryption is not available (enable ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true)`;
|
||||
}
|
||||
|
||||
export function formatMatrixEncryptedEventDisabledWarning(
|
||||
cfg: CoreConfig,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
return `matrix: encrypted event received without encryption enabled; set ${resolveMatrixEncryptionConfigPath(cfg, accountId)}=true and verify the device to decrypt`;
|
||||
}
|
||||
@@ -14,6 +14,19 @@ describe("markdownToMatrixHtml", () => {
|
||||
expect(html).toContain('<a href="https://example.com">docs</a>');
|
||||
});
|
||||
|
||||
it("does not auto-link bare file references into external urls", () => {
|
||||
const html = markdownToMatrixHtml("Check README.md and backup.sh");
|
||||
expect(html).toContain("README.md");
|
||||
expect(html).toContain("backup.sh");
|
||||
expect(html).not.toContain('href="http://README.md"');
|
||||
expect(html).not.toContain('href="http://backup.sh"');
|
||||
});
|
||||
|
||||
it("keeps real domains linked even when path segments look like filenames", () => {
|
||||
const html = markdownToMatrixHtml("See https://docs.example.com/backup.sh");
|
||||
expect(html).toContain('href="https://docs.example.com/backup.sh"');
|
||||
});
|
||||
|
||||
it("escapes raw HTML", () => {
|
||||
const html = markdownToMatrixHtml("<b>nope</b>");
|
||||
expect(html).toContain("<b>nope</b>");
|
||||
|
||||
@@ -11,10 +11,63 @@ md.enable("strikethrough");
|
||||
|
||||
const { escapeHtml } = md.utils;
|
||||
|
||||
/**
|
||||
* Keep bare file references like README.md from becoming external http:// links.
|
||||
* Telegram already hardens this path; Matrix should not turn common code/docs
|
||||
* filenames into clickable registrar-style URLs either.
|
||||
*/
|
||||
const FILE_EXTENSIONS_WITH_TLD = new Set(["md", "go", "py", "pl", "sh", "am", "at", "be", "cc"]);
|
||||
|
||||
function isAutoLinkedFileRef(href: string, label: string): boolean {
|
||||
const stripped = href.replace(/^https?:\/\//i, "");
|
||||
if (stripped !== label) {
|
||||
return false;
|
||||
}
|
||||
const dotIndex = label.lastIndexOf(".");
|
||||
if (dotIndex < 1) {
|
||||
return false;
|
||||
}
|
||||
const ext = label.slice(dotIndex + 1).toLowerCase();
|
||||
if (!FILE_EXTENSIONS_WITH_TLD.has(ext)) {
|
||||
return false;
|
||||
}
|
||||
const segments = label.split("/");
|
||||
if (segments.length > 1) {
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
if (segments[i]?.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSuppressAutoLink(
|
||||
tokens: Parameters<NonNullable<typeof md.renderer.rules.link_open>>[0],
|
||||
idx: number,
|
||||
): boolean {
|
||||
const token = tokens[idx];
|
||||
if (token?.type !== "link_open" || token.info !== "auto") {
|
||||
return false;
|
||||
}
|
||||
const href = token.attrGet("href") ?? "";
|
||||
const label = tokens[idx + 1]?.type === "text" ? (tokens[idx + 1]?.content ?? "") : "";
|
||||
return Boolean(href && label && isAutoLinkedFileRef(href, label));
|
||||
}
|
||||
|
||||
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 ?? "");
|
||||
md.renderer.rules.link_open = (tokens, idx, _options, _env, self) =>
|
||||
shouldSuppressAutoLink(tokens, idx) ? "" : self.renderToken(tokens, idx, _options);
|
||||
md.renderer.rules.link_close = (tokens, idx, _options, _env, self) => {
|
||||
const openIdx = idx - 2;
|
||||
if (openIdx >= 0 && shouldSuppressAutoLink(tokens, openIdx)) {
|
||||
return "";
|
||||
}
|
||||
return self.renderToken(tokens, idx, _options);
|
||||
};
|
||||
|
||||
export function markdownToMatrixHtml(markdown: string): string {
|
||||
const rendered = md.render(markdown ?? "");
|
||||
|
||||
@@ -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";
|
||||
95
extensions/matrix/src/matrix/legacy-crypto-inspector.ts
Normal file
95
extensions/matrix/src/matrix/legacy-crypto-inspector.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
|
||||
export type MatrixLegacyCryptoInspectionResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
function resolveLegacyMachineStorePath(params: {
|
||||
cryptoRootDir: string;
|
||||
deviceId: string;
|
||||
}): string | null {
|
||||
const hashedDir = path.join(
|
||||
params.cryptoRootDir,
|
||||
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
|
||||
);
|
||||
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return hashedDir;
|
||||
}
|
||||
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return params.cryptoRootDir;
|
||||
}
|
||||
const match = fs
|
||||
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
);
|
||||
return match ? path.join(params.cryptoRootDir, match.name) : null;
|
||||
}
|
||||
|
||||
export async function inspectLegacyMatrixCryptoStore(params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<MatrixLegacyCryptoInspectionResult> {
|
||||
const machineStorePath = resolveLegacyMachineStorePath(params);
|
||||
if (!machineStorePath) {
|
||||
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
|
||||
}
|
||||
|
||||
const requireFn = createRequire(import.meta.url);
|
||||
await ensureMatrixCryptoRuntime({
|
||||
requireFn,
|
||||
resolveFn: requireFn.resolve.bind(requireFn),
|
||||
log: params.log,
|
||||
});
|
||||
|
||||
const { DeviceId, OlmMachine, StoreType, UserId } = requireFn(
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
) as typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
const machine = await OlmMachine.initialize(
|
||||
new UserId(params.userId),
|
||||
new DeviceId(params.deviceId),
|
||||
machineStorePath,
|
||||
"",
|
||||
StoreType.Sqlite,
|
||||
);
|
||||
|
||||
try {
|
||||
const [backupKeys, roomKeyCounts] = await Promise.all([
|
||||
machine.getBackupKeys(),
|
||||
machine.roomKeyCounts(),
|
||||
]);
|
||||
return {
|
||||
deviceId: params.deviceId,
|
||||
roomKeyCounts: roomKeyCounts
|
||||
? {
|
||||
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
|
||||
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
|
||||
}
|
||||
: null,
|
||||
backupVersion:
|
||||
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
|
||||
? backupKeys.backupVersion
|
||||
: null,
|
||||
decryptionKeyBase64:
|
||||
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
|
||||
? backupKeys.decryptionKeyBase64
|
||||
: null,
|
||||
};
|
||||
} finally {
|
||||
machine.close();
|
||||
}
|
||||
}
|
||||
147
extensions/matrix/src/matrix/media-text.ts
Normal file
147
extensions/matrix/src/matrix/media-text.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
MatrixMessageAttachmentKind,
|
||||
MatrixMessageAttachmentSummary,
|
||||
MatrixMessageSummary,
|
||||
} from "./actions/types.js";
|
||||
|
||||
const MATRIX_MEDIA_KINDS: Record<string, MatrixMessageAttachmentKind> = {
|
||||
"m.audio": "audio",
|
||||
"m.file": "file",
|
||||
"m.image": "image",
|
||||
"m.sticker": "sticker",
|
||||
"m.video": "video",
|
||||
};
|
||||
|
||||
function resolveMatrixMediaKind(msgtype: string | undefined): MatrixMessageAttachmentKind | null {
|
||||
return MATRIX_MEDIA_KINDS[msgtype ?? ""] ?? null;
|
||||
}
|
||||
|
||||
function resolveMatrixMediaLabel(
|
||||
kind: MatrixMessageAttachmentKind | undefined,
|
||||
fallback = "media",
|
||||
): string {
|
||||
return `${kind ?? fallback} attachment`;
|
||||
}
|
||||
|
||||
function formatMatrixAttachmentMarker(params: {
|
||||
kind?: MatrixMessageAttachmentKind;
|
||||
unavailable?: boolean;
|
||||
}): string {
|
||||
const label = resolveMatrixMediaLabel(params.kind);
|
||||
return params.unavailable ? `[matrix ${label} unavailable]` : `[matrix ${label}]`;
|
||||
}
|
||||
|
||||
export function isLikelyBareFilename(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || trimmed.includes("\n") || /\s/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
if (path.basename(trimmed) !== trimmed) {
|
||||
return false;
|
||||
}
|
||||
return path.extname(trimmed).length > 1;
|
||||
}
|
||||
|
||||
function resolveCaptionOrFilename(params: { body?: string; filename?: string }): {
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
} {
|
||||
const body = params.body?.trim() ?? "";
|
||||
const filename = params.filename?.trim() ?? "";
|
||||
if (filename) {
|
||||
if (!body || body === filename) {
|
||||
return { filename };
|
||||
}
|
||||
return { caption: body, filename };
|
||||
}
|
||||
if (!body) {
|
||||
return {};
|
||||
}
|
||||
if (isLikelyBareFilename(body)) {
|
||||
return { filename: body };
|
||||
}
|
||||
return { caption: body };
|
||||
}
|
||||
|
||||
export function resolveMatrixMessageAttachment(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): MatrixMessageAttachmentSummary | undefined {
|
||||
const kind = resolveMatrixMediaKind(params.msgtype);
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved = resolveCaptionOrFilename(params);
|
||||
return {
|
||||
kind,
|
||||
caption: resolved.caption,
|
||||
filename: resolved.filename,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixMessageBody(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): string | undefined {
|
||||
const attachment = resolveMatrixMessageAttachment(params);
|
||||
if (!attachment) {
|
||||
const body = params.body?.trim() ?? "";
|
||||
return body || undefined;
|
||||
}
|
||||
return attachment.caption;
|
||||
}
|
||||
|
||||
export function formatMatrixAttachmentText(params: {
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
if (!params.attachment) {
|
||||
return undefined;
|
||||
}
|
||||
return formatMatrixAttachmentMarker({
|
||||
kind: params.attachment.kind,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMatrixMessageText(params: {
|
||||
body?: string;
|
||||
attachment?: MatrixMessageAttachmentSummary;
|
||||
unavailable?: boolean;
|
||||
}): string | undefined {
|
||||
const body = params.body?.trim() ?? "";
|
||||
const marker = formatMatrixAttachmentText({
|
||||
attachment: params.attachment,
|
||||
unavailable: params.unavailable,
|
||||
});
|
||||
if (!marker) {
|
||||
return body || undefined;
|
||||
}
|
||||
if (!body) {
|
||||
return marker;
|
||||
}
|
||||
return `${body}\n\n${marker}`;
|
||||
}
|
||||
|
||||
export function formatMatrixMessageSummaryText(
|
||||
summary: Pick<MatrixMessageSummary, "body" | "attachment">,
|
||||
): string | undefined {
|
||||
return formatMatrixMessageText(summary);
|
||||
}
|
||||
|
||||
export function formatMatrixMediaUnavailableText(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): string {
|
||||
return (
|
||||
formatMatrixMessageText({
|
||||
body: resolveMatrixMessageBody(params),
|
||||
attachment: resolveMatrixMessageAttachment(params),
|
||||
unavailable: true,
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { enforceMatrixDirectMessageAccess } from "./access-policy.js";
|
||||
|
||||
describe("enforceMatrixDirectMessageAccess", () => {
|
||||
it("issues pairing through the injected channel pairing challenge", async () => {
|
||||
const issuePairingChallenge = vi.fn(async () => ({ created: true, code: "123456" }));
|
||||
const sendPairingReply = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
enforceMatrixDirectMessageAccess({
|
||||
dmEnabled: true,
|
||||
dmPolicy: "pairing",
|
||||
accessDecision: "pairing",
|
||||
senderId: "@alice:example.com",
|
||||
senderName: "Alice",
|
||||
effectiveAllowFrom: [],
|
||||
issuePairingChallenge,
|
||||
sendPairingReply,
|
||||
logVerboseMessage: () => {},
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(issuePairingChallenge).toHaveBeenCalledTimes(1);
|
||||
expect(issuePairingChallenge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
senderId: "@alice:example.com",
|
||||
meta: { name: "Alice" },
|
||||
sendPairingReply,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import {
|
||||
formatAllowlistMatchMeta,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "../../../runtime-api.js";
|
||||
import {
|
||||
normalizeMatrixAllowList,
|
||||
resolveMatrixAllowListMatch,
|
||||
resolveMatrixAllowListMatches,
|
||||
} from "./allowlist.js";
|
||||
|
||||
type MatrixDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type MatrixGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
export async function resolveMatrixAccessState(params: {
|
||||
isDirectMessage: boolean;
|
||||
resolvedAccountId: string;
|
||||
dmPolicy: MatrixDmPolicy;
|
||||
groupPolicy: MatrixGroupPolicy;
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
readStoreForDmPolicy: (provider: string, accountId: string) => Promise<string[]>;
|
||||
}) {
|
||||
const storeAllowFrom = params.isDirectMessage
|
||||
? await readStoreAllowFromForDmPolicy({
|
||||
provider: "matrix",
|
||||
accountId: params.resolvedAccountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
readStore: params.readStoreForDmPolicy,
|
||||
})
|
||||
: [];
|
||||
const normalizedGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
|
||||
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
groupAllowFrom: normalizedGroupAllowFrom,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: !params.isDirectMessage,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: normalizedGroupAllowFrom,
|
||||
storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveMatrixAllowListMatches({
|
||||
allowList: normalizeMatrixAllowList(allowFrom),
|
||||
userId: params.senderId,
|
||||
}),
|
||||
});
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList(access.effectiveAllowFrom);
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(access.effectiveGroupAllowFrom);
|
||||
return {
|
||||
access,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function enforceMatrixDirectMessageAccess(params: {
|
||||
dmEnabled: boolean;
|
||||
dmPolicy: MatrixDmPolicy;
|
||||
accessDecision: "allow" | "block" | "pairing";
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
effectiveAllowFrom: string[];
|
||||
issuePairingChallenge: (params: {
|
||||
senderId: string;
|
||||
senderIdLine: string;
|
||||
meta?: Record<string, string | undefined>;
|
||||
buildReplyText: (params: { code: string }) => string;
|
||||
sendPairingReply: (text: string) => Promise<void>;
|
||||
onCreated: () => void;
|
||||
onReplyError: (err: unknown) => void;
|
||||
}) => Promise<{ created: boolean; code?: string }>;
|
||||
sendPairingReply: (text: string) => Promise<void>;
|
||||
logVerboseMessage: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.dmEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (params.accessDecision === "allow") {
|
||||
return true;
|
||||
}
|
||||
const allowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: params.effectiveAllowFrom,
|
||||
userId: params.senderId,
|
||||
});
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (params.accessDecision === "pairing") {
|
||||
await params.issuePairingChallenge({
|
||||
senderId: params.senderId,
|
||||
senderIdLine: `Matrix user id: ${params.senderId}`,
|
||||
meta: { name: params.senderName },
|
||||
buildReplyText: ({ code }) =>
|
||||
[
|
||||
"OpenClaw: access not configured.",
|
||||
"",
|
||||
`Pairing code: ${code}`,
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"openclaw pairing approve matrix <code>",
|
||||
].join("\n"),
|
||||
sendPairingReply: params.sendPairingReply,
|
||||
onCreated: () => {
|
||||
params.logVerboseMessage(
|
||||
`matrix pairing request sender=${params.senderId} name=${params.senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
params.logVerboseMessage(
|
||||
`matrix pairing reply failed for ${params.senderId}: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
params.logVerboseMessage(
|
||||
`matrix: blocked dm sender ${params.senderId} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
45
extensions/matrix/src/matrix/monitor/access-state.test.ts
Normal file
45
extensions/matrix/src/matrix/monitor/access-state.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixMonitorAccessState } from "./access-state.js";
|
||||
|
||||
describe("resolveMatrixMonitorAccessState", () => {
|
||||
it("normalizes effective allowlists once and exposes reusable matches", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
allowFrom: ["matrix:@Alice:Example.org"],
|
||||
storeAllowFrom: ["user:@bob:example.org"],
|
||||
groupAllowFrom: ["@Carol:Example.org"],
|
||||
roomUsers: ["user:@Dana:Example.org"],
|
||||
senderId: "@dana:example.org",
|
||||
isRoom: true,
|
||||
});
|
||||
|
||||
expect(state.effectiveAllowFrom).toEqual([
|
||||
"matrix:@alice:example.org",
|
||||
"user:@bob:example.org",
|
||||
]);
|
||||
expect(state.effectiveGroupAllowFrom).toEqual(["@carol:example.org"]);
|
||||
expect(state.effectiveRoomUsers).toEqual(["user:@dana:example.org"]);
|
||||
expect(state.directAllowMatch.allowed).toBe(false);
|
||||
expect(state.roomUserMatch?.allowed).toBe(true);
|
||||
expect(state.groupAllowMatch?.allowed).toBe(false);
|
||||
expect(state.commandAuthorizers).toEqual([
|
||||
{ configured: true, allowed: false },
|
||||
{ configured: true, allowed: true },
|
||||
{ configured: true, allowed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps room-user matching disabled for dm traffic", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: ["@carol:example.org"],
|
||||
roomUsers: ["@dana:example.org"],
|
||||
senderId: "@dana:example.org",
|
||||
isRoom: false,
|
||||
});
|
||||
|
||||
expect(state.roomUserMatch).toBeNull();
|
||||
expect(state.commandAuthorizers[1]).toEqual({ configured: true, allowed: false });
|
||||
expect(state.commandAuthorizers[2]).toEqual({ configured: true, allowed: false });
|
||||
});
|
||||
});
|
||||
77
extensions/matrix/src/matrix/monitor/access-state.ts
Normal file
77
extensions/matrix/src/matrix/monitor/access-state.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
import type { MatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
type MatrixCommandAuthorizer = {
|
||||
configured: boolean;
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
export type MatrixMonitorAccessState = {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
effectiveRoomUsers: string[];
|
||||
groupAllowConfigured: boolean;
|
||||
directAllowMatch: MatrixAllowListMatch;
|
||||
roomUserMatch: MatrixAllowListMatch | null;
|
||||
groupAllowMatch: MatrixAllowListMatch | null;
|
||||
commandAuthorizers: [MatrixCommandAuthorizer, MatrixCommandAuthorizer, MatrixCommandAuthorizer];
|
||||
};
|
||||
|
||||
export function resolveMatrixMonitorAccessState(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
storeAllowFrom: Array<string | number>;
|
||||
groupAllowFrom: Array<string | number>;
|
||||
roomUsers: Array<string | number>;
|
||||
senderId: string;
|
||||
isRoom: boolean;
|
||||
}): MatrixMonitorAccessState {
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList([
|
||||
...params.allowFrom,
|
||||
...params.storeAllowFrom,
|
||||
]);
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
|
||||
const effectiveRoomUsers = normalizeMatrixAllowList(params.roomUsers);
|
||||
|
||||
const directAllowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: params.senderId,
|
||||
});
|
||||
const roomUserMatch =
|
||||
params.isRoom && effectiveRoomUsers.length > 0
|
||||
? resolveMatrixAllowListMatch({
|
||||
allowList: effectiveRoomUsers,
|
||||
userId: params.senderId,
|
||||
})
|
||||
: null;
|
||||
const groupAllowMatch =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? resolveMatrixAllowListMatch({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: params.senderId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
effectiveRoomUsers,
|
||||
groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
directAllowMatch,
|
||||
roomUserMatch,
|
||||
groupAllowMatch,
|
||||
commandAuthorizers: [
|
||||
{
|
||||
configured: effectiveAllowFrom.length > 0,
|
||||
allowed: directAllowMatch.allowed,
|
||||
},
|
||||
{
|
||||
configured: effectiveRoomUsers.length > 0,
|
||||
allowed: roomUserMatch?.allowed ?? false,
|
||||
},
|
||||
{
|
||||
configured: effectiveGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowMatch?.allowed ?? false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
57
extensions/matrix/src/matrix/monitor/ack-config.test.ts
Normal file
57
extensions/matrix/src/matrix/monitor/ack-config.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
|
||||
|
||||
describe("resolveMatrixAckReactionConfig", () => {
|
||||
it("prefers account-level ack reaction and scope overrides", () => {
|
||||
expect(
|
||||
resolveMatrixAckReactionConfig({
|
||||
cfg: {
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "all",
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
ackReaction: "✅",
|
||||
ackReactionScope: "group-all",
|
||||
accounts: {
|
||||
ops: {
|
||||
ackReaction: "🟢",
|
||||
ackReactionScope: "direct",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "ops-agent",
|
||||
accountId: "ops",
|
||||
}),
|
||||
).toEqual({
|
||||
ackReaction: "🟢",
|
||||
ackReactionScope: "direct",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to channel then global settings", () => {
|
||||
expect(
|
||||
resolveMatrixAckReactionConfig({
|
||||
cfg: {
|
||||
messages: {
|
||||
ackReaction: "👀",
|
||||
ackReactionScope: "all",
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
ackReaction: "✅",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "ops-agent",
|
||||
accountId: "missing",
|
||||
}),
|
||||
).toEqual({
|
||||
ackReaction: "✅",
|
||||
ackReactionScope: "all",
|
||||
});
|
||||
});
|
||||
});
|
||||
27
extensions/matrix/src/matrix/monitor/ack-config.ts
Normal file
27
extensions/matrix/src/matrix/monitor/ack-config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { resolveAckReaction, type OpenClawConfig } from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAccountConfig } from "../accounts.js";
|
||||
|
||||
type MatrixAckReactionScope = "group-mentions" | "group-all" | "direct" | "all" | "none" | "off";
|
||||
|
||||
export function resolveMatrixAckReactionConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
}): { ackReaction: string; ackReactionScope: MatrixAckReactionScope } {
|
||||
const matrixConfig = params.cfg.channels?.matrix;
|
||||
const accountConfig = resolveMatrixAccountConfig({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const ackReaction = resolveAckReaction(params.cfg, params.agentId, {
|
||||
channel: "matrix",
|
||||
accountId: params.accountId ?? undefined,
|
||||
}).trim();
|
||||
const ackReactionScope =
|
||||
accountConfig.ackReactionScope ??
|
||||
matrixConfig?.ackReactionScope ??
|
||||
params.cfg.messages?.ackReactionScope ??
|
||||
"group-mentions";
|
||||
return { ackReaction, ackReactionScope };
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
compileAllowlist,
|
||||
normalizeStringEntries,
|
||||
resolveCompiledAllowlistMatch,
|
||||
resolveAllowlistMatchByCandidates,
|
||||
type AllowlistMatch,
|
||||
} from "../../../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
function normalizeAllowList(list?: Array<string | number>) {
|
||||
return normalizeStringEntries(list);
|
||||
@@ -70,23 +69,27 @@ export function normalizeMatrixAllowList(list?: Array<string | number>) {
|
||||
export type MatrixAllowListMatch = AllowlistMatch<
|
||||
"wildcard" | "id" | "prefixed-id" | "prefixed-user"
|
||||
>;
|
||||
type MatrixAllowListSource = Exclude<MatrixAllowListMatch["matchSource"], undefined>;
|
||||
|
||||
type MatrixAllowListMatchSource = NonNullable<MatrixAllowListMatch["matchSource"]>;
|
||||
|
||||
export function resolveMatrixAllowListMatch(params: {
|
||||
allowList: string[];
|
||||
userId?: string;
|
||||
}): MatrixAllowListMatch {
|
||||
const compiledAllowList = compileAllowlist(params.allowList);
|
||||
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: MatrixAllowListSource }> = [
|
||||
const candidates: Array<{ value?: string; source: MatrixAllowListMatchSource }> = [
|
||||
{ value: userId, source: "id" },
|
||||
{ value: userId ? `matrix:${userId}` : "", source: "prefixed-id" },
|
||||
{ value: userId ? `user:${userId}` : "", source: "prefixed-user" },
|
||||
];
|
||||
return resolveCompiledAllowlistMatch({
|
||||
compiledAllowlist: compiledAllowList,
|
||||
candidates,
|
||||
});
|
||||
return resolveAllowlistMatchByCandidates<MatrixAllowListMatchSource>({ allowList, candidates });
|
||||
}
|
||||
|
||||
export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) {
|
||||
|
||||
222
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal file
222
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixConfig } from "../../types.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
|
||||
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
|
||||
|
||||
function createClientStub() {
|
||||
let inviteHandler: InviteHandler | null = null;
|
||||
const client = {
|
||||
on: vi.fn((eventName: string, listener: unknown) => {
|
||||
if (eventName === "room.invite") {
|
||||
inviteHandler = listener as InviteHandler;
|
||||
}
|
||||
return client;
|
||||
}),
|
||||
joinRoom: vi.fn(async () => {}),
|
||||
resolveRoom: vi.fn(async () => null),
|
||||
} as unknown as import("../sdk.js").MatrixClient;
|
||||
|
||||
return {
|
||||
client,
|
||||
getInviteHandler: () => inviteHandler,
|
||||
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
|
||||
resolveRoom: (client as unknown as { resolveRoom: ReturnType<typeof vi.fn> }).resolveRoom,
|
||||
};
|
||||
}
|
||||
|
||||
describe("registerMatrixAutoJoin", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
});
|
||||
|
||||
it("joins all invites when autoJoin=always", async () => {
|
||||
const { client, getInviteHandler, joinRoom } = createClientStub();
|
||||
const accountConfig: MatrixConfig = {
|
||||
autoJoin: "always",
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("does not auto-join invites by default", async () => {
|
||||
const { client, getInviteHandler, joinRoom } = createClientStub();
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig: {},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(getInviteHandler()).toBeNull();
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
resolveRoom.mockResolvedValue(null);
|
||||
const accountConfig: MatrixConfig = {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("joins invite when allowlisted alias resolves to the invited room", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
resolveRoom.mockResolvedValue("!room:example.org");
|
||||
const accountConfig: MatrixConfig = {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: [" #allowed:example.org "],
|
||||
};
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("retries alias resolution after an unresolved lookup", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
resolveRoom.mockResolvedValueOnce(null).mockResolvedValueOnce("!room:example.org");
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(resolveRoom).toHaveBeenCalledTimes(2);
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
it("logs and skips allowlist alias resolution failures", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
const error = vi.fn();
|
||||
resolveRoom.mockRejectedValue(new Error("temporary homeserver failure"));
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error,
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await expect(inviteHandler!("!room:example.org", {})).resolves.toBeUndefined();
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("matrix: failed resolving allowlisted alias #allowed:example.org:"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not trust room-provided alias claims for allowlist joins", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
resolveRoom.mockResolvedValue("!different-room:example.org");
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#allowed:example.org"],
|
||||
},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses account-scoped auto-join settings for non-default accounts", async () => {
|
||||
const { client, getInviteHandler, joinRoom, resolveRoom } = createClientStub();
|
||||
resolveRoom.mockResolvedValue("!room:example.org");
|
||||
|
||||
registerMatrixAutoJoin({
|
||||
client,
|
||||
accountConfig: {
|
||||
autoJoin: "allowlist",
|
||||
autoJoinAllowlist: ["#ops-allowed:example.org"],
|
||||
},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
|
||||
});
|
||||
|
||||
const inviteHandler = getInviteHandler();
|
||||
expect(inviteHandler).toBeTruthy();
|
||||
await inviteHandler!("!room:example.org", {});
|
||||
|
||||
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { RuntimeEnv } from "../../../runtime-api.js";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { loadMatrixSdk } from "../sdk-runtime.js";
|
||||
import type { MatrixConfig } from "../../types.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
cfg: CoreConfig;
|
||||
accountConfig: Pick<MatrixConfig, "autoJoin" | "autoJoinAllowlist">;
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
const { client, cfg, runtime } = params;
|
||||
const { client, accountConfig, runtime } = params;
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) {
|
||||
@@ -17,49 +16,63 @@ export function registerMatrixAutoJoin(params: {
|
||||
}
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
const autoJoin = accountConfig.autoJoin ?? "off";
|
||||
const rawAllowlist = (accountConfig.autoJoinAllowlist ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const autoJoinAllowlist = new Set(rawAllowlist);
|
||||
const allowedRoomIds = new Set(rawAllowlist.filter((entry) => entry.startsWith("!")));
|
||||
const allowedAliases = rawAllowlist.filter((entry) => entry.startsWith("#"));
|
||||
const resolvedAliasRoomIds = new Map<string, string>();
|
||||
|
||||
if (autoJoin === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoJoin === "always") {
|
||||
// Use the built-in autojoin mixin for "always" mode
|
||||
const { AutojoinRoomsMixin } = loadMatrixSdk();
|
||||
AutojoinRoomsMixin.setupOnClient(client);
|
||||
logVerbose("matrix: auto-join enabled for all invites");
|
||||
return;
|
||||
} else {
|
||||
logVerbose("matrix: auto-join enabled for allowlist invites");
|
||||
}
|
||||
|
||||
// For "allowlist" mode, handle invites manually
|
||||
const resolveAllowedAliasRoomId = async (alias: string): Promise<string | null> => {
|
||||
if (resolvedAliasRoomIds.has(alias)) {
|
||||
return resolvedAliasRoomIds.get(alias) ?? null;
|
||||
}
|
||||
const resolved = await params.client.resolveRoom(alias);
|
||||
if (resolved) {
|
||||
resolvedAliasRoomIds.set(alias, resolved);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const resolveAllowedAliasRoomIds = async (): Promise<string[]> => {
|
||||
const resolved = await Promise.all(
|
||||
allowedAliases.map(async (alias) => {
|
||||
try {
|
||||
return await resolveAllowedAliasRoomId(alias);
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix: failed resolving allowlisted alias ${alias}: ${String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return resolved.filter((roomId): roomId is string => Boolean(roomId));
|
||||
};
|
||||
|
||||
// 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") {
|
||||
return;
|
||||
}
|
||||
if (autoJoin === "allowlist") {
|
||||
const allowedAliasRoomIds = await resolveAllowedAliasRoomIds();
|
||||
const allowed =
|
||||
autoJoinAllowlist.has("*") ||
|
||||
allowedRoomIds.has(roomId) ||
|
||||
allowedAliasRoomIds.some((resolvedRoomId) => resolvedRoomId === roomId);
|
||||
|
||||
// Get room alias if available
|
||||
let alias: string | undefined;
|
||||
let altAliases: string[] = [];
|
||||
try {
|
||||
const aliasState = await client
|
||||
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||
.catch(() => null);
|
||||
alias = aliasState?.alias;
|
||||
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const allowed =
|
||||
autoJoinAllowlist.includes("*") ||
|
||||
autoJoinAllowlist.includes(roomId) ||
|
||||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
197
extensions/matrix/src/matrix/monitor/config.test.ts
Normal file
197
extensions/matrix/src/matrix/monitor/config.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
|
||||
import { resolveMatrixMonitorConfig } from "./config.js";
|
||||
|
||||
type MatrixRoomsConfig = Record<string, MatrixRoomConfig>;
|
||||
|
||||
function createRuntime() {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
return runtime;
|
||||
}
|
||||
|
||||
describe("resolveMatrixMonitorConfig", () => {
|
||||
it("canonicalizes resolved user aliases and room keys without keeping stale aliases", async () => {
|
||||
const runtime = createRuntime();
|
||||
const resolveTargets = vi.fn(
|
||||
async ({ inputs, kind }: { inputs: string[]; kind: "user" | "group" }) => {
|
||||
if (kind === "user") {
|
||||
return inputs.map((input) => {
|
||||
if (input === "Bob") {
|
||||
return { input, resolved: true, id: "@bob:example.org" };
|
||||
}
|
||||
if (input === "Dana") {
|
||||
return { input, resolved: true, id: "@dana:example.org" };
|
||||
}
|
||||
return { input, resolved: false };
|
||||
});
|
||||
}
|
||||
return inputs.map((input) =>
|
||||
input === "General"
|
||||
? { input, resolved: true, id: "!general:example.org" }
|
||||
: { input, resolved: false },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const roomsConfig: MatrixRoomsConfig = {
|
||||
"*": { allow: true },
|
||||
"room:!ops:example.org": {
|
||||
allow: true,
|
||||
users: ["Dana", "user:@Erin:Example.org"],
|
||||
},
|
||||
General: {
|
||||
allow: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveMatrixMonitorConfig({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
allowFrom: ["matrix:@Alice:Example.org", "Bob"],
|
||||
groupAllowFrom: ["user:@Carol:Example.org"],
|
||||
roomsConfig,
|
||||
runtime,
|
||||
resolveTargets,
|
||||
});
|
||||
|
||||
expect(result.allowFrom).toEqual(["@alice:example.org", "@bob:example.org"]);
|
||||
expect(result.groupAllowFrom).toEqual(["@carol:example.org"]);
|
||||
expect(result.roomsConfig).toEqual({
|
||||
"*": { allow: true },
|
||||
"!ops:example.org": {
|
||||
allow: true,
|
||||
users: ["@dana:example.org", "@erin:example.org"],
|
||||
},
|
||||
"!general:example.org": {
|
||||
allow: true,
|
||||
},
|
||||
});
|
||||
expect(resolveTargets).toHaveBeenCalledTimes(3);
|
||||
expect(resolveTargets).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "user",
|
||||
inputs: ["Bob"],
|
||||
}),
|
||||
);
|
||||
expect(resolveTargets).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "group",
|
||||
inputs: ["General"],
|
||||
}),
|
||||
);
|
||||
expect(resolveTargets).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "user",
|
||||
inputs: ["Dana"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips config prefixes before lookups and logs unresolved guidance once per section", async () => {
|
||||
const runtime = createRuntime();
|
||||
const resolveTargets = vi.fn(
|
||||
async ({ kind, inputs }: { inputs: string[]; kind: "user" | "group" }) =>
|
||||
inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
...(kind === "group" ? { note: `missing ${input}` } : {}),
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await resolveMatrixMonitorConfig({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
allowFrom: ["user:Ghost"],
|
||||
groupAllowFrom: ["matrix:@known:example.org"],
|
||||
roomsConfig: {
|
||||
"channel:Project X": {
|
||||
allow: true,
|
||||
users: ["matrix:Ghost"],
|
||||
},
|
||||
},
|
||||
runtime,
|
||||
resolveTargets,
|
||||
});
|
||||
|
||||
expect(result.allowFrom).toEqual([]);
|
||||
expect(result.groupAllowFrom).toEqual(["@known:example.org"]);
|
||||
expect(result.roomsConfig).toEqual({});
|
||||
expect(resolveTargets).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "user",
|
||||
inputs: ["Ghost"],
|
||||
}),
|
||||
);
|
||||
expect(resolveTargets).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "group",
|
||||
inputs: ["Project X"],
|
||||
}),
|
||||
);
|
||||
expect(resolveTargets).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.log).toHaveBeenCalledWith("matrix dm allowlist unresolved: user:Ghost");
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"matrix dm allowlist entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.",
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith("matrix rooms unresolved: channel:Project X");
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves exact room aliases to canonical room ids instead of trusting alias keys directly", async () => {
|
||||
const runtime = createRuntime();
|
||||
const resolveTargets = vi.fn(
|
||||
async ({ kind, inputs }: { inputs: string[]; kind: "user" | "group" }) => {
|
||||
if (kind === "group") {
|
||||
return inputs.map((input) =>
|
||||
input === "#allowed:example.org"
|
||||
? { input, resolved: true, id: "!allowed-room:example.org" }
|
||||
: { input, resolved: false },
|
||||
);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const result = await resolveMatrixMonitorConfig({
|
||||
cfg: {} as CoreConfig,
|
||||
accountId: "ops",
|
||||
roomsConfig: {
|
||||
"#allowed:example.org": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
runtime,
|
||||
resolveTargets,
|
||||
});
|
||||
|
||||
expect(result.roomsConfig).toEqual({
|
||||
"!allowed-room:example.org": {
|
||||
allow: true,
|
||||
},
|
||||
});
|
||||
expect(resolveTargets).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
kind: "group",
|
||||
inputs: ["#allowed:example.org"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
306
extensions/matrix/src/matrix/monitor/config.ts
Normal file
306
extensions/matrix/src/matrix/monitor/config.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
canonicalizeAllowlistWithResolvedIds,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
|
||||
import { normalizeMatrixUserId } from "./allowlist.js";
|
||||
|
||||
type MatrixRoomsConfig = Record<string, MatrixRoomConfig>;
|
||||
type ResolveMatrixTargetsFn = typeof resolveMatrixTargets;
|
||||
|
||||
function normalizeMatrixUserLookupEntry(raw: string): string {
|
||||
return raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^user:/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeMatrixRoomLookupEntry(raw: string): string {
|
||||
return raw
|
||||
.replace(/^matrix:/i, "")
|
||||
.replace(/^(room|channel):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isMatrixQualifiedUserId(value: string): boolean {
|
||||
return value.startsWith("@") && value.includes(":");
|
||||
}
|
||||
|
||||
function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] {
|
||||
return entries.filter((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return true;
|
||||
}
|
||||
return isMatrixQualifiedUserId(normalizeMatrixUserLookupEntry(trimmed));
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeMatrixRoomUserAllowlists(entries: MatrixRoomsConfig): MatrixRoomsConfig {
|
||||
const nextEntries: MatrixRoomsConfig = { ...entries };
|
||||
for (const [roomKey, roomConfig] of Object.entries(entries)) {
|
||||
const users = roomConfig?.users;
|
||||
if (!Array.isArray(users)) {
|
||||
continue;
|
||||
}
|
||||
nextEntries[roomKey] = {
|
||||
...roomConfig,
|
||||
users: filterResolvedMatrixAllowlistEntries(users.map(String)),
|
||||
};
|
||||
}
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
async function resolveMatrixMonitorUserEntries(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
entries: Array<string | number>;
|
||||
runtime: RuntimeEnv;
|
||||
resolveTargets: ResolveMatrixTargetsFn;
|
||||
}) {
|
||||
const directMatches: Array<{ input: string; resolved: boolean; id?: string }> = [];
|
||||
const pending: Array<{ input: string; query: string }> = [];
|
||||
|
||||
for (const entry of params.entries) {
|
||||
const input = String(entry).trim();
|
||||
if (!input) {
|
||||
continue;
|
||||
}
|
||||
const query = normalizeMatrixUserLookupEntry(input);
|
||||
if (!query || query === "*") {
|
||||
continue;
|
||||
}
|
||||
if (isMatrixQualifiedUserId(query)) {
|
||||
directMatches.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: normalizeMatrixUserId(query),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
pending.push({ input, query });
|
||||
}
|
||||
|
||||
const pendingResolved =
|
||||
pending.length === 0
|
||||
? []
|
||||
: await params.resolveTargets({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
inputs: pending.map((entry) => entry.query),
|
||||
kind: "user",
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
pendingResolved.forEach((entry, index) => {
|
||||
const source = pending[index];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
directMatches.push({
|
||||
input: source.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id ? normalizeMatrixUserId(entry.id) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return buildAllowlistResolutionSummary(directMatches);
|
||||
}
|
||||
|
||||
async function resolveMatrixMonitorUserAllowlist(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
label: string;
|
||||
list?: Array<string | number>;
|
||||
runtime: RuntimeEnv;
|
||||
resolveTargets: ResolveMatrixTargetsFn;
|
||||
}): Promise<string[]> {
|
||||
const allowList = (params.list ?? []).map(String);
|
||||
if (allowList.length === 0) {
|
||||
return allowList;
|
||||
}
|
||||
|
||||
const resolution = await resolveMatrixMonitorUserEntries({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
entries: allowList,
|
||||
runtime: params.runtime,
|
||||
resolveTargets: params.resolveTargets,
|
||||
});
|
||||
const canonicalized = canonicalizeAllowlistWithResolvedIds({
|
||||
existing: allowList,
|
||||
resolvedMap: resolution.resolvedMap,
|
||||
});
|
||||
|
||||
summarizeMapping(params.label, resolution.mapping, resolution.unresolved, params.runtime);
|
||||
if (resolution.unresolved.length > 0) {
|
||||
params.runtime.log?.(
|
||||
`${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`,
|
||||
);
|
||||
}
|
||||
|
||||
return filterResolvedMatrixAllowlistEntries(canonicalized);
|
||||
}
|
||||
|
||||
async function resolveMatrixMonitorRoomsConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
roomsConfig?: MatrixRoomsConfig;
|
||||
runtime: RuntimeEnv;
|
||||
resolveTargets: ResolveMatrixTargetsFn;
|
||||
}): Promise<MatrixRoomsConfig | undefined> {
|
||||
const roomsConfig = params.roomsConfig;
|
||||
if (!roomsConfig || Object.keys(roomsConfig).length === 0) {
|
||||
return roomsConfig;
|
||||
}
|
||||
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextRooms: MatrixRoomsConfig = {};
|
||||
if (roomsConfig["*"]) {
|
||||
nextRooms["*"] = roomsConfig["*"];
|
||||
}
|
||||
|
||||
const pending: Array<{ input: string; query: string; config: MatrixRoomConfig }> = [];
|
||||
for (const [entry, roomConfig] of Object.entries(roomsConfig)) {
|
||||
if (entry === "*") {
|
||||
continue;
|
||||
}
|
||||
const input = entry.trim();
|
||||
if (!input) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = normalizeMatrixRoomLookupEntry(input);
|
||||
if (!cleaned) {
|
||||
unresolved.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||
if (!nextRooms[cleaned]) {
|
||||
nextRooms[cleaned] = roomConfig;
|
||||
}
|
||||
if (cleaned !== input) {
|
||||
mapping.push(`${input}→${cleaned}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
pending.push({ input, query: cleaned, config: roomConfig });
|
||||
}
|
||||
|
||||
if (pending.length > 0) {
|
||||
const resolved = await params.resolveTargets({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
inputs: pending.map((entry) => entry.query),
|
||||
kind: "group",
|
||||
runtime: params.runtime,
|
||||
});
|
||||
resolved.forEach((entry, index) => {
|
||||
const source = pending[index];
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
if (entry.resolved && entry.id) {
|
||||
const roomKey = normalizeMatrixRoomLookupEntry(entry.id);
|
||||
if (!nextRooms[roomKey]) {
|
||||
nextRooms[roomKey] = source.config;
|
||||
}
|
||||
mapping.push(`${source.input}→${roomKey}`);
|
||||
} else {
|
||||
unresolved.push(source.input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
summarizeMapping("matrix rooms", mapping, unresolved, params.runtime);
|
||||
if (unresolved.length > 0) {
|
||||
params.runtime.log?.(
|
||||
"matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.",
|
||||
);
|
||||
}
|
||||
|
||||
const roomUsers = new Set<string>();
|
||||
for (const roomConfig of Object.values(nextRooms)) {
|
||||
addAllowlistUserEntriesFromConfigEntry(roomUsers, roomConfig);
|
||||
}
|
||||
if (roomUsers.size === 0) {
|
||||
return nextRooms;
|
||||
}
|
||||
|
||||
const resolution = await resolveMatrixMonitorUserEntries({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
entries: Array.from(roomUsers),
|
||||
runtime: params.runtime,
|
||||
resolveTargets: params.resolveTargets,
|
||||
});
|
||||
summarizeMapping("matrix room users", resolution.mapping, resolution.unresolved, params.runtime);
|
||||
if (resolution.unresolved.length > 0) {
|
||||
params.runtime.log?.(
|
||||
"matrix room users entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.",
|
||||
);
|
||||
}
|
||||
|
||||
const patched = patchAllowlistUsersInConfigEntries({
|
||||
entries: nextRooms,
|
||||
resolvedMap: resolution.resolvedMap,
|
||||
strategy: "canonicalize",
|
||||
});
|
||||
return sanitizeMatrixRoomUserAllowlists(patched);
|
||||
}
|
||||
|
||||
export async function resolveMatrixMonitorConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
roomsConfig?: MatrixRoomsConfig;
|
||||
runtime: RuntimeEnv;
|
||||
resolveTargets?: ResolveMatrixTargetsFn;
|
||||
}): Promise<{
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
roomsConfig?: MatrixRoomsConfig;
|
||||
}> {
|
||||
const resolveTargets = params.resolveTargets ?? resolveMatrixTargets;
|
||||
|
||||
const [allowFrom, groupAllowFrom, roomsConfig] = await Promise.all([
|
||||
resolveMatrixMonitorUserAllowlist({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
label: "matrix dm allowlist",
|
||||
list: params.allowFrom,
|
||||
runtime: params.runtime,
|
||||
resolveTargets,
|
||||
}),
|
||||
resolveMatrixMonitorUserAllowlist({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
label: "matrix group allowlist",
|
||||
list: params.groupAllowFrom,
|
||||
runtime: params.runtime,
|
||||
resolveTargets,
|
||||
}),
|
||||
resolveMatrixMonitorRoomsConfig({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
roomsConfig: params.roomsConfig,
|
||||
runtime: params.runtime,
|
||||
resolveTargets,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
allowFrom,
|
||||
groupAllowFrom,
|
||||
roomsConfig,
|
||||
};
|
||||
}
|
||||
@@ -1,396 +1,193 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- minimal MatrixClient stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StateEvent = Record<string, unknown>;
|
||||
type DmMap = Record<string, boolean>;
|
||||
const brokenDmRoomId = "!broken-dm:example.org";
|
||||
const defaultBrokenDmMembers = ["@alice:example.org", "@bot:example.org"];
|
||||
|
||||
function createMockClient(opts: {
|
||||
dmRooms?: DmMap;
|
||||
membersByRoom?: Record<string, string[]>;
|
||||
stateEvents?: Record<string, StateEvent>;
|
||||
selfUserId?: string;
|
||||
}) {
|
||||
const {
|
||||
dmRooms = {},
|
||||
membersByRoom = {},
|
||||
stateEvents = {},
|
||||
selfUserId = "@bot:example.org",
|
||||
} = opts;
|
||||
|
||||
function createMockClient(params: { isDm?: boolean; members?: string[] }) {
|
||||
let members = params.members ?? ["@alice:example.org", "@bot:example.org"];
|
||||
return {
|
||||
dms: {
|
||||
isDm: (roomId: string) => dmRooms[roomId] ?? false,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
isDm: vi.fn().mockReturnValue(params.isDm === true),
|
||||
},
|
||||
getUserId: vi.fn().mockResolvedValue(selfUserId),
|
||||
getJoinedRoomMembers: vi.fn().mockImplementation(async (roomId: string) => {
|
||||
return membersByRoom[roomId] ?? [];
|
||||
}),
|
||||
getRoomStateEvent: vi
|
||||
.fn()
|
||||
.mockImplementation(async (roomId: string, eventType: string, stateKey: string) => {
|
||||
const key = `${roomId}|${eventType}|${stateKey}`;
|
||||
const ev = stateEvents[key];
|
||||
if (ev === undefined) {
|
||||
// Simulate real homeserver M_NOT_FOUND response (matches MatrixError shape)
|
||||
const err = new Error(`State event not found: ${key}`) as Error & {
|
||||
errcode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
err.errcode = "M_NOT_FOUND";
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
return ev;
|
||||
}),
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||
getJoinedRoomMembers: vi.fn().mockImplementation(async () => members),
|
||||
__setMembers(next: string[]) {
|
||||
members = next;
|
||||
},
|
||||
} as unknown as MatrixClient & {
|
||||
dms: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
isDm: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
|
||||
__setMembers: (members: string[]) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function createBrokenDmClient(roomNameEvent?: StateEvent) {
|
||||
return createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
[brokenDmRoomId]: defaultBrokenDmMembers,
|
||||
},
|
||||
stateEvents: {
|
||||
// is_direct not set on either member (e.g. Continuwuity bug)
|
||||
[`${brokenDmRoomId}|m.room.member|@alice:example.org`]: {},
|
||||
[`${brokenDmRoomId}|m.room.member|@bot:example.org`]: {},
|
||||
...(roomNameEvent ? { [`${brokenDmRoomId}|m.room.name|`]: roomNameEvent } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests -- isDirectMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createDirectRoomTracker", () => {
|
||||
describe("m.direct detection (SDK DM cache)", () => {
|
||||
it("returns true when SDK DM cache marks room as DM", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: { "!dm:example.org": true },
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for rooms not in SDK DM cache (with >2 members)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("is_direct state flag detection", () => {
|
||||
it("returns true when sender's membership has is_direct=true", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
||||
"!room:example.org|m.room.member|@bot:example.org": { is_direct: false },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("treats m.direct rooms as DMs", async () => {
|
||||
const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when bot's own membership has is_direct=true", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: false },
|
||||
"!room:example.org|m.room.member|@bot:example.org": { is_direct: true },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("does not trust stale m.direct classifications for shared rooms", async () => {
|
||||
const tracker = createDirectRoomTracker(
|
||||
createMockClient({
|
||||
isDm: true,
|
||||
members: ["@alice:example.org", "@bot:example.org", "@extra:example.org"],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
selfUserId: "@bot:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
describe("conservative fallback (memberCount + room name)", () => {
|
||||
it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => {
|
||||
const client = createBrokenDmClient();
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: brokenDmRoomId,
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 2-member room with empty room name", async () => {
|
||||
const client = createBrokenDmClient({ name: "" });
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: brokenDmRoomId,
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 2-member room WITH a room name (named group)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!named-group:example.org": ["@alice:example.org", "@bob:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!named-group:example.org|m.room.member|@alice:example.org": {},
|
||||
"!named-group:example.org|m.room.member|@bob:example.org": {},
|
||||
"!named-group:example.org|m.room.name|": { name: "Project Alpha" },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!named-group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 3+ member room without any DM signals", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!group:example.org|m.room.member|@alice:example.org": {},
|
||||
"!group:example.org|m.room.member|@bob:example.org": {},
|
||||
"!group:example.org|m.room.member|@carol:example.org": {},
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 1-member room (self-chat)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!solo:example.org": ["@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!solo:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!solo:example.org",
|
||||
senderId: "@bot:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detection priority", () => {
|
||||
it("m.direct takes priority -- skips state and fallback checks", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: { "!dm:example.org": true },
|
||||
membersByRoom: {
|
||||
"!dm:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!dm:example.org|m.room.name|": { name: "Named Room" },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not have checked member state or room name
|
||||
expect(client.getRoomStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is_direct takes priority over fallback -- skips member count", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("classifies 2-member rooms as DMs when direct metadata is missing", async () => {
|
||||
const client = createMockClient({ isDm: false });
|
||||
const tracker = createDirectRoomTracker(client);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not have checked member count
|
||||
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org");
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles member count API failure gracefully", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
stateEvents: {
|
||||
"!failing:example.org|m.room.member|@alice:example.org": {},
|
||||
"!failing:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
client.getJoinedRoomMembers.mockRejectedValue(new Error("API unavailable"));
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
it("does not classify rooms with extra members as DMs", async () => {
|
||||
const tracker = createDirectRoomTracker(
|
||||
createMockClient({
|
||||
isDm: false,
|
||||
members: ["@alice:example.org", "@bot:example.org", "@observer:example.org"],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!failing:example.org",
|
||||
it("does not classify 2-member rooms whose sender is not a joined member as DMs", async () => {
|
||||
const tracker = createDirectRoomTracker(
|
||||
createMockClient({
|
||||
isDm: false,
|
||||
members: ["@mallory:example.org", "@bot:example.org"],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("re-checks room membership after invalidation when a DM gains extra members", async () => {
|
||||
const client = createMockClient({ isDm: true });
|
||||
const tracker = createDirectRoomTracker(client);
|
||||
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
client.__setMembers(["@alice:example.org", "@bot:example.org", "@mallory:example.org"]);
|
||||
|
||||
tracker.invalidateRoom("!room:example.org");
|
||||
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("still recognizes exact 2-member rooms when member state also claims is_direct", async () => {
|
||||
const tracker = createDirectRoomTracker(createMockClient({}));
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("ignores member-state is_direct when the room is not a strict DM", async () => {
|
||||
const tracker = createDirectRoomTracker(
|
||||
createMockClient({
|
||||
members: ["@alice:example.org", "@bot:example.org", "@observer:example.org"],
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("bounds joined-room membership cache size", async () => {
|
||||
const client = createMockClient({ isDm: false });
|
||||
const tracker = createDirectRoomTracker(client);
|
||||
|
||||
for (let i = 0; i <= 1024; i += 1) {
|
||||
await tracker.isDirectMessage({
|
||||
roomId: `!room-${i}:example.org`,
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot determine member count -> conservative: classify as group
|
||||
expect(result).toBe(false);
|
||||
await tracker.isDirectMessage({
|
||||
roomId: "!room-0:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
it("treats M_NOT_FOUND for room name as no name (DM)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!no-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!no-name:example.org|m.room.member|@alice:example.org": {},
|
||||
"!no-name:example.org|m.room.member|@bot:example.org": {},
|
||||
// m.room.name not in stateEvents -> mock throws generic Error
|
||||
},
|
||||
});
|
||||
// Override to throw M_NOT_FOUND like a real homeserver
|
||||
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
||||
client.getRoomStateEvent.mockImplementation(
|
||||
async (roomId: string, eventType: string, stateKey: string) => {
|
||||
if (eventType === "m.room.name") {
|
||||
const err = new Error("not found") as Error & {
|
||||
errcode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
err.errcode = "M_NOT_FOUND";
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
return originalImpl(roomId, eventType, stateKey);
|
||||
},
|
||||
);
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(1026);
|
||||
});
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!no-name:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
it("refreshes dm and membership caches after the ttl expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-12T10:00:00Z"));
|
||||
const client = createMockClient({ isDm: true });
|
||||
const tracker = createDirectRoomTracker(client);
|
||||
|
||||
expect(result).toBe(true);
|
||||
await tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
await tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
it("treats non-404 room name errors as unknown (falls through to group)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!error-room:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!error-room:example.org|m.room.member|@alice:example.org": {},
|
||||
"!error-room:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
// Simulate a network/auth error (not M_NOT_FOUND)
|
||||
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
||||
client.getRoomStateEvent.mockImplementation(
|
||||
async (roomId: string, eventType: string, stateKey: string) => {
|
||||
if (eventType === "m.room.name") {
|
||||
throw new Error("Connection refused");
|
||||
}
|
||||
return originalImpl(roomId, eventType, stateKey);
|
||||
},
|
||||
);
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
expect(client.dms.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(1);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!error-room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
vi.setSystemTime(new Date("2026-03-12T10:00:31Z"));
|
||||
|
||||
// Network error -> don't assume DM, classify as group
|
||||
expect(result).toBe(false);
|
||||
await tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
it("whitespace-only room name is treated as no name", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!ws-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!ws-name:example.org|m.room.member|@alice:example.org": {},
|
||||
"!ws-name:example.org|m.room.member|@bot:example.org": {},
|
||||
"!ws-name:example.org|m.room.name|": { name: " " },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!ws-name:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
expect(client.dms.update).toHaveBeenCalledTimes(2);
|
||||
expect(client.getJoinedRoomMembers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { isStrictDirectMembership, readJoinedMatrixMembers } from "../direct-room.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
type DirectMessageCheck = {
|
||||
roomId: string;
|
||||
@@ -8,27 +9,26 @@ type DirectMessageCheck = {
|
||||
|
||||
type DirectRoomTrackerOptions = {
|
||||
log?: (message: string) => void;
|
||||
includeMemberCountInLogs?: boolean;
|
||||
};
|
||||
|
||||
const DM_CACHE_TTL_MS = 30_000;
|
||||
const MAX_TRACKED_DM_ROOMS = 1024;
|
||||
|
||||
/**
|
||||
* Check if an error is a Matrix M_NOT_FOUND response (missing state event).
|
||||
* The bot-sdk throws MatrixError with errcode/statusCode on the error object.
|
||||
*/
|
||||
function isMatrixNotFoundError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as { errcode?: string; statusCode?: number };
|
||||
return e.errcode === "M_NOT_FOUND" || e.statusCode === 404;
|
||||
function rememberBounded<T>(map: Map<string, T>, key: string, value: T): void {
|
||||
map.set(key, value);
|
||||
if (map.size > MAX_TRACKED_DM_ROOMS) {
|
||||
const oldest = map.keys().next().value;
|
||||
if (typeof oldest === "string") {
|
||||
map.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
|
||||
const log = opts.log ?? (() => {});
|
||||
const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
|
||||
let lastDmUpdateMs = 0;
|
||||
let cachedSelfUserId: string | null = null;
|
||||
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||
const joinedMembersCache = new Map<string, { members: string[]; ts: number }>();
|
||||
|
||||
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||
if (cachedSelfUserId) {
|
||||
@@ -55,97 +55,66 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||
}
|
||||
};
|
||||
|
||||
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
|
||||
const cached = memberCountCache.get(roomId);
|
||||
const resolveJoinedMembers = async (roomId: string): Promise<string[] | null> => {
|
||||
const cached = joinedMembersCache.get(roomId);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
|
||||
return cached.count;
|
||||
return cached.members;
|
||||
}
|
||||
try {
|
||||
const members = await client.getJoinedRoomMembers(roomId);
|
||||
const count = members.length;
|
||||
memberCountCache.set(roomId, { count, ts: now });
|
||||
return count;
|
||||
const normalized = await readJoinedMatrixMembers(client, roomId);
|
||||
if (!normalized) {
|
||||
throw new Error("membership unavailable");
|
||||
}
|
||||
rememberBounded(joinedMembersCache, roomId, { members: normalized, ts: now });
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
|
||||
log(`matrix: dm member lookup 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 {
|
||||
invalidateRoom: (roomId: string): void => {
|
||||
joinedMembersCache.delete(roomId);
|
||||
lastDmUpdateMs = 0;
|
||||
log(`matrix: invalidated dm cache room=${roomId}`);
|
||||
},
|
||||
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
|
||||
const { roomId, senderId } = params;
|
||||
await refreshDmCache();
|
||||
|
||||
// Check m.direct account data (most authoritative)
|
||||
if (client.dms.isDm(roomId)) {
|
||||
log(`matrix: dm detected via m.direct room=${roomId}`);
|
||||
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}`);
|
||||
const joinedMembers = await resolveJoinedMembers(roomId);
|
||||
|
||||
if (client.dms.isDm(roomId)) {
|
||||
const directViaAccountData = Boolean(
|
||||
isStrictDirectMembership({
|
||||
selfUserId,
|
||||
remoteUserId: senderId,
|
||||
joinedMembers,
|
||||
}),
|
||||
);
|
||||
if (directViaAccountData) {
|
||||
log(`matrix: dm detected via m.direct room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
log(`matrix: ignoring stale m.direct classification room=${roomId}`);
|
||||
}
|
||||
|
||||
if (
|
||||
isStrictDirectMembership({
|
||||
selfUserId,
|
||||
remoteUserId: senderId,
|
||||
joinedMembers,
|
||||
})
|
||||
) {
|
||||
log(`matrix: dm detected via exact 2-member room room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative fallback: 2-member rooms without an explicit room name are likely
|
||||
// DMs with broken m.direct / is_direct flags. This has been observed on Continuwuity
|
||||
// where m.direct pointed to the wrong room and is_direct was never set on the invite.
|
||||
// Unlike the removed heuristic, this requires two signals (member count + no name)
|
||||
// to avoid false positives on named 2-person group rooms.
|
||||
//
|
||||
// Performance: member count is cached (resolveMemberCount). The room name state
|
||||
// check is not cached but only runs for the subset of 2-member rooms that reach
|
||||
// this fallback path (no m.direct, no is_direct). In typical deployments this is
|
||||
// a small minority of rooms.
|
||||
//
|
||||
// Note: there is a narrow race where a room name is being set concurrently with
|
||||
// this check. The consequence is a one-time misclassification that self-corrects
|
||||
// on the next message (once the state event is synced). This is acceptable given
|
||||
// the alternative of an additional API call on every message.
|
||||
const memberCount = await resolveMemberCount(roomId);
|
||||
if (memberCount === 2) {
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "");
|
||||
if (!nameState?.name?.trim()) {
|
||||
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Missing state events (M_NOT_FOUND) are expected for unnamed rooms and
|
||||
// strongly indicate a DM. Any other error (network, auth) is ambiguous,
|
||||
// so we fall through to classify as group rather than guess.
|
||||
if (isMatrixNotFoundError(err)) {
|
||||
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
log(
|
||||
`matrix: dm fallback skipped (room name check failed: ${String(err)}) room=${roomId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeMemberCountInLogs) {
|
||||
log(`matrix: dm check room=${roomId} result=group`);
|
||||
return false;
|
||||
}
|
||||
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
|
||||
log(
|
||||
`matrix: dm check room=${roomId} result=group members=${joinedMembers?.length ?? "unknown"}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user