diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 1536a7c08ac..4d9d0fa0e4f 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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-.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__HOMESERVER` +- `MATRIX__ACCESS_TOKEN` +- `MATRIX__USER_ID` +- `MATRIX__PASSWORD` +- `MATRIX__DEVICE_ID` +- `MATRIX__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//__//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 "" +``` + +Verbose device verification details: + +```bash +openclaw matrix verify device "" --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 `. +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 ` 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//__//`. +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..ackReaction` +- `channels["matrix"].ackReaction` +- `messages.ackReaction` +- agent identity emoji fallback + +Ack reaction scope resolves in this order: + +- `channels["matrix"].accounts..ackReactionScope` +- `channels["matrix"].ackReactionScope` +- `messages.ackReactionScope` + +Reaction notification mode resolves in this order: + +- `channels["matrix"].accounts..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 +``` + +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 ` for CLI commands that rely on implicit account selection. +Pass `--account ` 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 ` -- 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`). diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md new file mode 100644 index 00000000000..d1e85c5ecd1 --- /dev/null +++ b/docs/install/migrating-matrix.md @@ -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 "" + ``` + +6. If this device is still unverified, run: + + ```bash + openclaw matrix verify device "" + ``` + +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 " 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 ""`. + +`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 ""`. + +`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 ""` 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 ', 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 ' with the matching recovery key.` + +- Meaning: the stored key does not match the active Matrix backup. +- What to do: rerun `openclaw matrix verify device ""` 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 '.` + +- 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 ""`. + +`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 ' first.` + +- Meaning: this device cannot restore from secret storage until device verification is complete. +- What to do: run `openclaw matrix verify device ""` 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 "" --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) diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts index 8f7fe4d268b..620864b9a90 100644 --- a/extensions/matrix/api.ts +++ b/extensions/matrix/api.ts @@ -1,2 +1,3 @@ export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; +export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js"; diff --git a/extensions/matrix/helper-api.ts b/extensions/matrix/helper-api.ts new file mode 100644 index 00000000000..1ed6a08fbc3 --- /dev/null +++ b/extensions/matrix/helper-api.ts @@ -0,0 +1,3 @@ +export * from "./src/account-selection.js"; +export * from "./src/env-vars.js"; +export * from "./src/storage-paths.js"; diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 08e9133197c..6fecfa5ffa3 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -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"] }, + ); + }, }); diff --git a/extensions/matrix/legacy-crypto-inspector.ts b/extensions/matrix/legacy-crypto-inspector.ts new file mode 100644 index 00000000000..de34f3c5c33 --- /dev/null +++ b/extensions/matrix/legacy-crypto-inspector.ts @@ -0,0 +1,2 @@ +export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js"; +export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js"; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 34a2512bb35..605751f6ccd 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -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" + ] } } } diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index f9079d7430a..9d427c4ac8c 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1 +1,3 @@ export * from "openclaw/plugin-sdk/matrix"; +export * from "./src/auth-precedence.js"; +export * from "./helper-api.js"; diff --git a/extensions/matrix/src/account-selection.ts b/extensions/matrix/src/account-selection.ts new file mode 100644 index 00000000000..51bf75061b2 --- /dev/null +++ b/extensions/matrix/src/account-selection.ts @@ -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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +export function findMatrixAccountEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | 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(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)); +} diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts new file mode 100644 index 00000000000..0675fb2e440 --- /dev/null +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -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 { + 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 }, + ); + }); +}); diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts new file mode 100644 index 00000000000..f9da97881ac --- /dev/null +++ b/extensions/matrix/src/actions.test.ts @@ -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 } | 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([]); + }); +}); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index e3ef491213f..57f19b938df 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -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([ + "send", + "poll-vote", + "react", + "reactions", + "read", + "edit", + "delete", + "pin", + "unpin", + "list-pins", + "set-profile", + "member-info", + "channel-info", + "permissions", +]); + +function createMatrixExposedActions(params: { + gate: ReturnType; + encryptionEnabled: boolean; +}) { + const actions = new Set(["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 { + 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(["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) => + 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 = { + "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.`); diff --git a/extensions/matrix/src/auth-precedence.ts b/extensions/matrix/src/auth-precedence.ts new file mode 100644 index 00000000000..244a7eb9e90 --- /dev/null +++ b/extensions/matrix/src/auth-precedence.ts @@ -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; + +type MatrixStringSourceMap = Partial>; + +const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set([ + "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; +} diff --git a/extensions/matrix/src/channel.account-paths.test.ts b/extensions/matrix/src/channel.account-paths.test.ts new file mode 100644 index 00000000000..bd9d13651ca --- /dev/null +++ b/extensions/matrix/src/channel.account-paths.test.ts @@ -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("./matrix/send.js"); + return { + ...actual, + sendMessageMatrix: (...args: unknown[]) => sendMessageMatrixMock(...args), + }; +}); + +vi.mock("./matrix/probe.js", async () => { + const actual = await vi.importActual("./matrix/probe.js"); + return { + ...actual, + probeMatrix: (...args: unknown[]) => probeMatrixMock(...args), + }; +}); + +vi.mock("./matrix/client.js", async () => { + const actual = await vi.importActual("./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", + }); + }); +}); diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index ca0f25e7e77..8f79f592db8 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -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(); }); }); diff --git a/extensions/matrix/src/channel.resolve.test.ts b/extensions/matrix/src/channel.resolve.test.ts new file mode 100644 index 00000000000..aff3b30119f --- /dev/null +++ b/extensions/matrix/src/channel.resolve.test.ts @@ -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), + }), + }); + }); +}); diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts index 475d53629e1..e75d06f1875 100644 --- a/extensions/matrix/src/channel.runtime.ts +++ b/extensions/matrix/src/channel.runtime.ts @@ -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, }; diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts new file mode 100644 index 00000000000..07f61ef3469 --- /dev/null +++ b/extensions/matrix/src/channel.setup.test.ts @@ -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; + } + } + } + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 894488da567..cf251450fd2 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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, @@ -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 = { id: "matrix", meta, @@ -129,9 +195,11 @@ export const matrixPlugin: ChannelPlugin = { 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 = { account, cfg: cfg as CoreConfig, }), - collectMatrixSecurityWarnings, + collectMatrixSecurityWarningsForAccount, ), }, groups: { @@ -179,7 +247,12 @@ export const matrixPlugin: ChannelPlugin = { 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 = { }), }), 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 = { }, }), }, + 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 = { accessToken: auth.accessToken, userId: auth.userId, timeoutMs, + accountId: account.accountId, }); } catch (err) { return { diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts new file mode 100644 index 00000000000..a97c083ebce --- /dev/null +++ b/extensions/matrix/src/cli.test.ts @@ -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; accountId: string }) => ({ + ...cfg, + channels: { + ...(cfg.channels as Record | 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 --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 --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 '.", + ); + }); + + 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 --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"); + }); +}); diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts new file mode 100644 index 00000000000..9fc08308d35 --- /dev/null +++ b/extensions/matrix/src/cli.ts @@ -0,0 +1,1182 @@ +import type { Command } from "commander"; +import { + formatZonedTimestamp, + normalizeAccountId, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/matrix"; +import { resolveMatrixAccount, resolveMatrixAccountConfig } from "./matrix/accounts.js"; +import { withResolvedActionClient, withStartedActionClient } from "./matrix/actions/client.js"; +import { listMatrixOwnDevices, pruneMatrixStaleGatewayDevices } from "./matrix/actions/devices.js"; +import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; +import { + bootstrapMatrixVerification, + getMatrixRoomKeyBackupStatus, + getMatrixVerificationStatus, + resetMatrixRoomKeyBackup, + restoreMatrixRoomKeyBackup, + verifyMatrixRecoveryKey, +} from "./matrix/actions/verification.js"; +import { resolveMatrixRoomKeyBackupIssue } from "./matrix/backup-health.js"; +import { resolveMatrixAuthContext } from "./matrix/client.js"; +import { setMatrixSdkConsoleLogging, setMatrixSdkLogMode } from "./matrix/client/logging.js"; +import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { isOpenClawManagedMatrixDevice } from "./matrix/device-health.js"; +import { + inspectMatrixDirectRooms, + repairMatrixDirectRooms, + type MatrixDirectRoomCandidate, +} from "./matrix/direct-management.js"; +import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js"; +import { getMatrixRuntime } from "./runtime.js"; +import { maybeBootstrapNewEncryptedMatrixAccount } from "./setup-bootstrap.js"; +import { matrixSetupAdapter } from "./setup-core.js"; +import type { CoreConfig } from "./types.js"; + +let matrixCliExitScheduled = false; + +function scheduleMatrixCliExit(): void { + if (matrixCliExitScheduled || process.env.VITEST) { + return; + } + matrixCliExitScheduled = true; + // matrix-js-sdk rust crypto can leave background async work alive after command completion. + setTimeout(() => { + process.exit(process.exitCode ?? 0); + }, 0); +} + +function markCliFailure(): void { + process.exitCode = 1; +} + +function toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +function formatLocalTimestamp(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) { + return value; + } + return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value; +} + +function printTimestamp(label: string, value: string | null | undefined): void { + const formatted = formatLocalTimestamp(value); + if (formatted) { + console.log(`${label}: ${formatted}`); + } +} + +function printAccountLabel(accountId?: string): void { + console.log(`Account: ${normalizeAccountId(accountId)}`); +} + +function resolveMatrixCliAccountId(accountId?: string): string { + const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + return resolveMatrixAuthContext({ cfg, accountId }).accountId; +} + +function formatMatrixCliCommand(command: string, accountId?: string): string { + const normalizedAccountId = normalizeAccountId(accountId); + const suffix = normalizedAccountId === "default" ? "" : ` --account ${normalizedAccountId}`; + return `openclaw matrix ${command}${suffix}`; +} + +function printMatrixOwnDevices( + devices: Array<{ + deviceId: string; + displayName: string | null; + lastSeenIp: string | null; + lastSeenTs: number | null; + current: boolean; + }>, +): void { + if (devices.length === 0) { + console.log("Devices: none"); + return; + } + for (const device of devices) { + const labels = [device.current ? "current" : null, device.displayName].filter(Boolean); + console.log(`- ${device.deviceId}${labels.length ? ` (${labels.join(", ")})` : ""}`); + if (device.lastSeenTs) { + printTimestamp(" Last seen", new Date(device.lastSeenTs).toISOString()); + } + if (device.lastSeenIp) { + console.log(` Last IP: ${device.lastSeenIp}`); + } + } +} + +function configureCliLogMode(verbose: boolean): void { + setMatrixSdkLogMode(verbose ? "default" : "quiet"); + setMatrixSdkConsoleLogging(verbose); +} + +function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} must be an integer`); + } + return parsed; +} + +type MatrixCliAccountAddResult = { + accountId: string; + configPath: string; + useEnv: boolean; + deviceHealth: { + currentDeviceId: string | null; + staleOpenClawDeviceIds: string[]; + error?: string; + }; + verificationBootstrap: { + attempted: boolean; + success: boolean; + recoveryKeyCreatedAt: string | null; + backupVersion: string | null; + error?: string; + }; + profile: { + attempted: boolean; + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + convertedAvatarFromHttp: boolean; + error?: string; + }; +}; + +async function addMatrixAccount(params: { + account?: string; + name?: string; + avatarUrl?: string; + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: string; + useEnv?: boolean; +}): Promise { + const runtime = getMatrixRuntime(); + const cfg = runtime.config.loadConfig() as CoreConfig; + if (!matrixSetupAdapter.applyAccountConfig) { + throw new Error("Matrix account setup is unavailable."); + } + + const input: ChannelSetupInput & { avatarUrl?: string } = { + name: params.name, + avatarUrl: params.avatarUrl, + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + password: params.password, + deviceName: params.deviceName, + initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"), + useEnv: params.useEnv === true, + }; + const accountId = + matrixSetupAdapter.resolveAccountId?.({ + cfg, + accountId: params.account, + input, + }) ?? normalizeAccountId(params.account?.trim() || params.name?.trim()); + const validationError = matrixSetupAdapter.validateInput?.({ + cfg, + accountId, + input, + }); + if (validationError) { + throw new Error(validationError); + } + + const updated = matrixSetupAdapter.applyAccountConfig({ + cfg, + accountId, + input, + }) as CoreConfig; + await runtime.config.writeConfigFile(updated as never); + const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId }); + + let verificationBootstrap: MatrixCliAccountAddResult["verificationBootstrap"] = { + attempted: false, + success: false, + recoveryKeyCreatedAt: null, + backupVersion: null, + }; + if (accountConfig.encryption === true) { + verificationBootstrap = await maybeBootstrapNewEncryptedMatrixAccount({ + previousCfg: cfg, + cfg: updated, + accountId, + }); + } + + const desiredDisplayName = input.name?.trim(); + const desiredAvatarUrl = input.avatarUrl?.trim(); + let profile: MatrixCliAccountAddResult["profile"] = { + attempted: false, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + }; + if (desiredDisplayName || desiredAvatarUrl) { + try { + const synced = await updateMatrixOwnProfile({ + accountId, + displayName: desiredDisplayName, + avatarUrl: desiredAvatarUrl, + }); + let resolvedAvatarUrl = synced.resolvedAvatarUrl; + if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) { + const latestCfg = runtime.config.loadConfig() as CoreConfig; + const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, { + avatarUrl: synced.resolvedAvatarUrl, + }); + await runtime.config.writeConfigFile(withAvatar as never); + resolvedAvatarUrl = synced.resolvedAvatarUrl; + } + profile = { + attempted: true, + displayNameUpdated: synced.displayNameUpdated, + avatarUpdated: synced.avatarUpdated, + resolvedAvatarUrl, + convertedAvatarFromHttp: synced.convertedAvatarFromHttp, + }; + } catch (err) { + profile = { + attempted: true, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + error: toErrorMessage(err), + }; + } + } + + let deviceHealth: MatrixCliAccountAddResult["deviceHealth"] = { + currentDeviceId: null, + staleOpenClawDeviceIds: [], + }; + try { + const addedDevices = await listMatrixOwnDevices({ accountId }); + deviceHealth = { + currentDeviceId: addedDevices.find((device) => device.current)?.deviceId ?? null, + staleOpenClawDeviceIds: addedDevices + .filter((device) => !device.current && isOpenClawManagedMatrixDevice(device.displayName)) + .map((device) => device.deviceId), + }; + } catch (err) { + deviceHealth = { + currentDeviceId: null, + staleOpenClawDeviceIds: [], + error: toErrorMessage(err), + }; + } + + return { + accountId, + configPath: resolveMatrixConfigPath(updated, accountId), + useEnv: input.useEnv === true, + deviceHealth, + verificationBootstrap, + profile, + }; +} + +function printDirectRoomCandidate(room: MatrixCliDirectRoomCandidate): void { + const members = + room.joinedMembers === null ? "unavailable" : room.joinedMembers.join(", ") || "none"; + console.log( + `- ${room.roomId} [${room.source}] strict=${room.strict ? "yes" : "no"} joined=${members}`, + ); +} + +function printDirectRoomInspection(result: MatrixCliDirectRoomInspection): void { + printAccountLabel(result.accountId); + console.log(`Peer: ${result.remoteUserId}`); + console.log(`Self: ${result.selfUserId ?? "unknown"}`); + console.log(`Active direct room: ${result.activeRoomId ?? "none"}`); + console.log( + `Mapped rooms: ${result.mappedRoomIds.length ? result.mappedRoomIds.join(", ") : "none"}`, + ); + console.log( + `Discovered strict rooms: ${result.discoveredStrictRoomIds.length ? result.discoveredStrictRoomIds.join(", ") : "none"}`, + ); + if (result.mappedRooms.length > 0) { + console.log("Mapped room details:"); + for (const room of result.mappedRooms) { + printDirectRoomCandidate(room); + } + } +} + +async function inspectMatrixDirectRoom(params: { + accountId: string; + userId: string; +}): Promise { + return await withResolvedActionClient( + { accountId: params.accountId }, + async (client) => { + const inspection = await inspectMatrixDirectRooms({ + client, + remoteUserId: params.userId, + }); + return { + accountId: params.accountId, + remoteUserId: inspection.remoteUserId, + selfUserId: inspection.selfUserId, + mappedRoomIds: inspection.mappedRoomIds, + mappedRooms: inspection.mappedRooms.map(toCliDirectRoomCandidate), + discoveredStrictRoomIds: inspection.discoveredStrictRoomIds, + activeRoomId: inspection.activeRoomId, + }; + }, + "persist", + ); +} + +async function repairMatrixDirectRoom(params: { + accountId: string; + userId: string; +}): Promise { + const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; + const account = resolveMatrixAccount({ cfg, accountId: params.accountId }); + return await withStartedActionClient({ accountId: params.accountId }, async (client) => { + const repaired = await repairMatrixDirectRooms({ + client, + remoteUserId: params.userId, + encrypted: account.config.encryption === true, + }); + return { + accountId: params.accountId, + remoteUserId: repaired.remoteUserId, + selfUserId: repaired.selfUserId, + mappedRoomIds: repaired.mappedRoomIds, + mappedRooms: repaired.mappedRooms.map(toCliDirectRoomCandidate), + discoveredStrictRoomIds: repaired.discoveredStrictRoomIds, + activeRoomId: repaired.activeRoomId, + encrypted: account.config.encryption === true, + createdRoomId: repaired.createdRoomId, + changed: repaired.changed, + directContentBefore: repaired.directContentBefore, + directContentAfter: repaired.directContentAfter, + }; + }); +} + +type MatrixCliProfileSetResult = MatrixProfileUpdateResult; + +async function setMatrixProfile(params: { + account?: string; + name?: string; + avatarUrl?: string; +}): Promise { + return await applyMatrixProfileUpdate({ + account: params.account, + displayName: params.name, + avatarUrl: params.avatarUrl, + }); +} + +type MatrixCliCommandConfig = { + verbose: boolean; + json: boolean; + run: () => Promise; + onText: (result: TResult, verbose: boolean) => void; + onJson?: (result: TResult) => unknown; + shouldFail?: (result: TResult) => boolean; + errorPrefix: string; + onJsonError?: (message: string) => unknown; +}; + +async function runMatrixCliCommand( + config: MatrixCliCommandConfig, +): Promise { + configureCliLogMode(config.verbose); + try { + const result = await config.run(); + if (config.json) { + printJson(config.onJson ? config.onJson(result) : result); + } else { + config.onText(result, config.verbose); + } + if (config.shouldFail?.(result)) { + markCliFailure(); + } + } catch (err) { + const message = toErrorMessage(err); + if (config.json) { + printJson(config.onJsonError ? config.onJsonError(message) : { error: message }); + } else { + console.error(`${config.errorPrefix}: ${message}`); + } + markCliFailure(); + } finally { + scheduleMatrixCliExit(); + } +} + +type MatrixCliBackupStatus = { + serverVersion: string | null; + activeVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + decryptionKeyCached: boolean | null; + keyLoadAttempted: boolean; + keyLoadError: string | null; +}; + +type MatrixCliVerificationStatus = { + encryptionEnabled: boolean; + verified: boolean; + userId: string | null; + deviceId: string | null; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + backupVersion: string | null; + backup?: MatrixCliBackupStatus; + recoveryKeyStored: boolean; + recoveryKeyCreatedAt: string | null; + pendingVerifications: number; +}; + +type MatrixCliDirectRoomCandidate = { + roomId: string; + source: "account-data" | "joined"; + strict: boolean; + joinedMembers: string[] | null; +}; + +type MatrixCliDirectRoomInspection = { + accountId: string; + remoteUserId: string; + selfUserId: string | null; + mappedRoomIds: string[]; + mappedRooms: MatrixCliDirectRoomCandidate[]; + discoveredStrictRoomIds: string[]; + activeRoomId: string | null; +}; + +type MatrixCliDirectRoomRepair = MatrixCliDirectRoomInspection & { + encrypted: boolean; + createdRoomId: string | null; + changed: boolean; + directContentBefore: Record; + directContentAfter: Record; +}; + +function toCliDirectRoomCandidate(room: MatrixDirectRoomCandidate): MatrixCliDirectRoomCandidate { + return { + roomId: room.roomId, + source: room.source, + strict: room.strict, + joinedMembers: room.joinedMembers, + }; +} + +function resolveBackupStatus(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): MatrixCliBackupStatus { + return { + serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null, + activeVersion: status.backup?.activeVersion ?? null, + trusted: status.backup?.trusted ?? null, + matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null, + decryptionKeyCached: status.backup?.decryptionKeyCached ?? null, + keyLoadAttempted: status.backup?.keyLoadAttempted ?? false, + keyLoadError: status.backup?.keyLoadError ?? null, + }; +} + +function yesNoUnknown(value: boolean | null): string { + if (value === true) { + return "yes"; + } + if (value === false) { + return "no"; + } + return "unknown"; +} + +function printBackupStatus(backup: MatrixCliBackupStatus): void { + console.log(`Backup server version: ${backup.serverVersion ?? "none"}`); + console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`); + console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`); + console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`); + console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`); + console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`); + if (backup.keyLoadError) { + console.log(`Backup key load error: ${backup.keyLoadError}`); + } +} + +function printVerificationIdentity(status: { + userId: string | null; + deviceId: string | null; +}): void { + console.log(`User: ${status.userId ?? "unknown"}`); + console.log(`Device: ${status.deviceId ?? "unknown"}`); +} + +function printVerificationBackupSummary(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): void { + printBackupSummary(resolveBackupStatus(status)); +} + +function printVerificationBackupStatus(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): void { + printBackupStatus(resolveBackupStatus(status)); +} + +function printVerificationTrustDiagnostics(status: { + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; +}): void { + console.log(`Locally trusted: ${status.localVerified ? "yes" : "no"}`); + console.log(`Cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`); + console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`); +} + +function printVerificationGuidance(status: MatrixCliVerificationStatus, accountId?: string): void { + printGuidance(buildVerificationGuidance(status, accountId)); +} + +function printBackupSummary(backup: MatrixCliBackupStatus): void { + const issue = resolveMatrixRoomKeyBackupIssue(backup); + console.log(`Backup: ${issue.summary}`); + if (backup.serverVersion) { + console.log(`Backup version: ${backup.serverVersion}`); + } +} + +function buildVerificationGuidance( + status: MatrixCliVerificationStatus, + accountId?: string, +): string[] { + const backup = resolveBackupStatus(status); + const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); + const nextSteps = new Set(); + if (!status.verified) { + nextSteps.add( + `Run '${formatMatrixCliCommand("verify device ", accountId)}' to verify this device.`, + ); + } + if (backupIssue.code === "missing-server-backup") { + nextSteps.add( + `Run '${formatMatrixCliCommand("verify bootstrap", accountId)}' to create a room key backup.`, + ); + } else if ( + backupIssue.code === "key-load-failed" || + backupIssue.code === "key-not-loaded" || + backupIssue.code === "inactive" + ) { + if (status.recoveryKeyStored) { + nextSteps.add( + `Backup key is not loaded on this device. Run '${formatMatrixCliCommand("verify backup restore", accountId)}' to load it and restore old room keys.`, + ); + } else { + nextSteps.add( + `Store a recovery key with '${formatMatrixCliCommand("verify device ", accountId)}', then run '${formatMatrixCliCommand("verify backup restore", accountId)}'.`, + ); + } + } else if (backupIssue.code === "key-mismatch") { + nextSteps.add( + `Backup key mismatch on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' with the matching recovery key.`, + ); + nextSteps.add( + `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`, + ); + } else if (backupIssue.code === "untrusted-signature") { + nextSteps.add( + `Backup trust chain is not verified on this device. Re-run '${formatMatrixCliCommand("verify device ", accountId)}' if you have the correct recovery key.`, + ); + nextSteps.add( + `If you want a fresh backup baseline and accept losing unrecoverable history, run '${formatMatrixCliCommand("verify backup reset --yes", accountId)}'.`, + ); + } else if (backupIssue.code === "indeterminate") { + nextSteps.add( + `Run '${formatMatrixCliCommand("verify status --verbose", accountId)}' to inspect backup trust diagnostics.`, + ); + } + if (status.pendingVerifications > 0) { + nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`); + } + return Array.from(nextSteps); +} + +function printGuidance(lines: string[]): void { + if (lines.length === 0) { + return; + } + console.log("Next steps:"); + for (const line of lines) { + console.log(`- ${line}`); + } +} + +function printVerificationStatus( + status: MatrixCliVerificationStatus, + verbose = false, + accountId?: string, +): void { + console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`); + const backup = resolveBackupStatus(status); + const backupIssue = resolveMatrixRoomKeyBackupIssue(backup); + printVerificationBackupSummary(status); + if (backupIssue.message) { + console.log(`Backup issue: ${backupIssue.message}`); + } + if (verbose) { + console.log("Diagnostics:"); + printVerificationIdentity(status); + printVerificationTrustDiagnostics(status); + printVerificationBackupStatus(status); + console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); + printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt); + console.log(`Pending verifications: ${status.pendingVerifications}`); + } else { + console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); + } + printVerificationGuidance(status, accountId); +} + +export function registerMatrixCli(params: { program: Command }): void { + const root = params.program + .command("matrix") + .description("Matrix channel utilities") + .addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix\n"); + + const account = root.command("account").description("Manage matrix channel accounts"); + + account + .command("add") + .description("Add or update a matrix account (wrapper around channel setup)") + .option("--account ", "Account ID (default: normalized --name, else default)") + .option("--name ", "Optional display name for this account") + .option("--avatar-url ", "Optional Matrix avatar URL (mxc:// or http(s) URL)") + .option("--homeserver ", "Matrix homeserver URL") + .option("--user-id ", "Matrix user ID") + .option("--access-token ", "Matrix access token") + .option("--password ", "Matrix password") + .option("--device-name ", "Matrix device display name") + .option("--initial-sync-limit ", "Matrix initial sync limit") + .option( + "--use-env", + "Use MATRIX_* env vars (or MATRIX__* for non-default accounts)", + ) + .option("--verbose", "Show setup details") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + name?: string; + avatarUrl?: string; + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: string; + useEnv?: boolean; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await addMatrixAccount({ + account: options.account, + name: options.name, + avatarUrl: options.avatarUrl, + homeserver: options.homeserver, + userId: options.userId, + accessToken: options.accessToken, + password: options.password, + deviceName: options.deviceName, + initialSyncLimit: options.initialSyncLimit, + useEnv: options.useEnv === true, + }), + onText: (result) => { + console.log(`Saved matrix account: ${result.accountId}`); + console.log(`Config path: ${result.configPath}`); + console.log( + `Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX__* env vars" : "inline config"}`, + ); + if (result.verificationBootstrap.attempted) { + if (result.verificationBootstrap.success) { + console.log("Matrix verification bootstrap: complete"); + printTimestamp( + "Recovery key created at", + result.verificationBootstrap.recoveryKeyCreatedAt, + ); + if (result.verificationBootstrap.backupVersion) { + console.log(`Backup version: ${result.verificationBootstrap.backupVersion}`); + } + } else { + console.error( + `Matrix verification bootstrap warning: ${result.verificationBootstrap.error}`, + ); + } + } + if (result.deviceHealth.error) { + console.error(`Matrix device health warning: ${result.deviceHealth.error}`); + } else if (result.deviceHealth.staleOpenClawDeviceIds.length > 0) { + console.log( + `Matrix device hygiene warning: stale OpenClaw devices detected (${result.deviceHealth.staleOpenClawDeviceIds.join(", ")}). Run 'openclaw matrix devices prune-stale --account ${result.accountId}'.`, + ); + } + if (result.profile.attempted) { + if (result.profile.error) { + console.error(`Profile sync warning: ${result.profile.error}`); + } else { + console.log( + `Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, + ); + if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) { + console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`); + } + } + } + const bindHint = `openclaw agents bind --agent --bind matrix:${result.accountId}`; + console.log(`Bind this account to an agent: ${bindHint}`); + }, + errorPrefix: "Account setup failed", + }); + }, + ); + + const profile = root.command("profile").description("Manage Matrix bot profile"); + + profile + .command("set") + .description("Update Matrix profile display name and/or avatar") + .option("--account ", "Account ID (for multi-account setups)") + .option("--name ", "Profile display name") + .option("--avatar-url ", "Profile avatar URL (mxc:// or http(s) URL)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + name?: string; + avatarUrl?: string; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await setMatrixProfile({ + account: options.account, + name: options.name, + avatarUrl: options.avatarUrl, + }), + onText: (result) => { + printAccountLabel(result.accountId); + console.log(`Config path: ${result.configPath}`); + console.log( + `Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, + ); + if (result.profile.convertedAvatarFromHttp && result.avatarUrl) { + console.log(`Avatar converted and saved as: ${result.avatarUrl}`); + } + }, + errorPrefix: "Profile update failed", + }); + }, + ); + + const direct = root.command("direct").description("Inspect and repair Matrix direct-room state"); + + direct + .command("inspect") + .description("Inspect direct-room mappings for a Matrix user") + .requiredOption("--user-id ", "Peer Matrix user ID") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { userId: string; account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await inspectMatrixDirectRoom({ + accountId, + userId: options.userId, + }), + onText: (result) => { + printDirectRoomInspection(result); + }, + errorPrefix: "Direct room inspection failed", + }); + }, + ); + + direct + .command("repair") + .description("Repair Matrix direct-room mappings for a Matrix user") + .requiredOption("--user-id ", "Peer Matrix user ID") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { userId: string; account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await repairMatrixDirectRoom({ + accountId, + userId: options.userId, + }), + onText: (result, verbose) => { + printDirectRoomInspection(result); + console.log(`Encrypted room creation: ${result.encrypted ? "enabled" : "disabled"}`); + console.log(`Created room: ${result.createdRoomId ?? "none"}`); + console.log(`m.direct updated: ${result.changed ? "yes" : "no"}`); + if (verbose) { + console.log( + `m.direct before: ${JSON.stringify(result.directContentBefore[result.remoteUserId] ?? [])}`, + ); + console.log( + `m.direct after: ${JSON.stringify(result.directContentAfter[result.remoteUserId] ?? [])}`, + ); + } + }, + errorPrefix: "Direct room repair failed", + }); + }, + ); + + const verify = root.command("verify").description("Device verification for Matrix E2EE"); + + verify + .command("status") + .description("Check Matrix device verification status") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--include-recovery-key", "Include stored recovery key in output") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + verbose?: boolean; + includeRecoveryKey?: boolean; + json?: boolean; + }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await getMatrixVerificationStatus({ + accountId, + includeRecoveryKey: options.includeRecoveryKey === true, + }), + onText: (status, verbose) => { + printAccountLabel(accountId); + printVerificationStatus(status, verbose, accountId); + }, + errorPrefix: "Error", + }); + }, + ); + + const backup = verify.command("backup").description("Matrix room-key backup health and restore"); + + backup + .command("status") + .description("Show Matrix room-key backup status for this device") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await getMatrixRoomKeyBackupStatus({ accountId }), + onText: (status, verbose) => { + printAccountLabel(accountId); + printBackupSummary(status); + if (verbose) { + printBackupStatus(status); + } + }, + errorPrefix: "Backup status failed", + }); + }); + + backup + .command("reset") + .description("Delete the current server backup and create a fresh room-key backup baseline") + .option("--account ", "Account ID (for multi-account setups)") + .option("--yes", "Confirm destructive backup reset", false) + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => { + if (options.yes !== true) { + throw new Error("Refusing to reset Matrix room-key backup without --yes"); + } + return await resetMatrixRoomKeyBackup({ accountId }); + }, + onText: (result, verbose) => { + printAccountLabel(accountId); + console.log(`Reset success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Previous backup version: ${result.previousVersion ?? "none"}`); + console.log(`Deleted backup version: ${result.deletedVersion ?? "none"}`); + console.log(`Current backup version: ${result.createdVersion ?? "none"}`); + printBackupSummary(result.backup); + if (verbose) { + printTimestamp("Reset at", result.resetAt); + printBackupStatus(result.backup); + } + }, + shouldFail: (result) => !result.success, + errorPrefix: "Backup reset failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + backup + .command("restore") + .description("Restore encrypted room keys from server backup") + .option("--account ", "Account ID (for multi-account setups)") + .option("--recovery-key ", "Optional recovery key to load before restoring") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + recoveryKey?: string; + verbose?: boolean; + json?: boolean; + }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await restoreMatrixRoomKeyBackup({ + accountId, + recoveryKey: options.recoveryKey, + }), + onText: (result, verbose) => { + printAccountLabel(accountId); + console.log(`Restore success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Backup version: ${result.backupVersion ?? "none"}`); + console.log(`Imported keys: ${result.imported}/${result.total}`); + printBackupSummary(result.backup); + if (verbose) { + console.log( + `Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`, + ); + printTimestamp("Restored at", result.restoredAt); + printBackupStatus(result.backup); + } + }, + shouldFail: (result) => !result.success, + errorPrefix: "Backup restore failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + verify + .command("bootstrap") + .description("Bootstrap Matrix cross-signing and device verification state") + .option("--account ", "Account ID (for multi-account setups)") + .option("--recovery-key ", "Recovery key to apply before bootstrap") + .option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + recoveryKey?: string; + forceResetCrossSigning?: boolean; + verbose?: boolean; + json?: boolean; + }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await bootstrapMatrixVerification({ + accountId, + recoveryKey: options.recoveryKey, + forceResetCrossSigning: options.forceResetCrossSigning === true, + }), + onText: (result, verbose) => { + printAccountLabel(accountId); + console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`); + printVerificationIdentity(result.verification); + if (verbose) { + printVerificationTrustDiagnostics(result.verification); + console.log( + `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`, + ); + printVerificationBackupStatus(result.verification); + printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt); + console.log(`Pending verifications: ${result.pendingVerifications}`); + } else { + console.log( + `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`, + ); + printVerificationBackupSummary(result.verification); + } + printVerificationGuidance( + { + ...result.verification, + pendingVerifications: result.pendingVerifications, + }, + accountId, + ); + }, + shouldFail: (result) => !result.success, + errorPrefix: "Verification bootstrap failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + verify + .command("device ") + .description("Verify device using a Matrix recovery key") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await verifyMatrixRecoveryKey(key, { accountId }), + onText: (result, verbose) => { + printAccountLabel(accountId); + if (!result.success) { + console.error(`Verification failed: ${result.error ?? "unknown error"}`); + return; + } + console.log("Device verification completed successfully."); + printVerificationIdentity(result); + printVerificationBackupSummary(result); + if (verbose) { + printVerificationTrustDiagnostics(result); + printVerificationBackupStatus(result); + printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt); + printTimestamp("Verified at", result.verifiedAt); + } + printVerificationGuidance( + { + ...result, + pendingVerifications: 0, + }, + accountId, + ); + }, + shouldFail: (result) => !result.success, + errorPrefix: "Verification failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + const devices = root.command("devices").description("Inspect and clean up Matrix devices"); + + devices + .command("list") + .description("List server-side Matrix devices for this account") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await listMatrixOwnDevices({ accountId }), + onText: (result) => { + printAccountLabel(accountId); + printMatrixOwnDevices(result); + }, + errorPrefix: "Device listing failed", + }); + }); + + devices + .command("prune-stale") + .description("Delete stale OpenClaw-managed devices for this account") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { + const accountId = resolveMatrixCliAccountId(options.account); + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await pruneMatrixStaleGatewayDevices({ accountId }), + onText: (result, verbose) => { + printAccountLabel(accountId); + console.log( + `Deleted stale OpenClaw devices: ${result.deletedDeviceIds.length ? result.deletedDeviceIds.join(", ") : "none"}`, + ); + console.log(`Current device: ${result.currentDeviceId ?? "unknown"}`); + console.log(`Remaining devices: ${result.remainingDevices.length}`); + if (verbose) { + console.log("Devices before cleanup:"); + printMatrixOwnDevices(result.before); + console.log("Devices after cleanup:"); + printMatrixOwnDevices(result.remainingDevices); + } + }, + errorPrefix: "Device cleanup failed", + }); + }); +} diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 22a8e3c3aec..82d186dfa37 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -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, diff --git a/extensions/matrix/src/directory-live.test.ts b/extensions/matrix/src/directory-live.test.ts index bc0b1202005..fd186daafc1 100644 --- a/extensions/matrix/src/directory-live.test.ts +++ b/extensions/matrix/src/directory-live.test.ts @@ -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(); }); }); diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts index 68f1cf15b0c..32f8bc36bee 100644 --- a/extensions/matrix/src/directory-live.ts +++ b/extensions/matrix/src/directory-live.ts @@ -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>; -async function fetchMatrixJson(params: { - homeserver: string; - path: string; - accessToken: string; - method?: "GET" | "POST"; - body?: unknown; -}): Promise { - 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( + client: MatrixAuthedHttpClient, + params: { + method: "GET" | "POST"; + endpoint: string; + body?: unknown; + }, +): Promise { + 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 { @@ -92,14 +104,16 @@ export async function listMatrixDirectoryPeersLive( if (!context) { return []; } - const { query, auth } = context; - const res = await fetchMatrixJson({ - 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(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 { try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + const res = await requestMatrixJson(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 { try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, + const res = await requestMatrixJson(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({ - homeserver: auth.homeserver, - accessToken: auth.accessToken, - path: "/_matrix/client/v3/joined_rooms", + const joined = await requestMatrixJson(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({ diff --git a/extensions/matrix/src/env-vars.ts b/extensions/matrix/src/env-vars.ts new file mode 100644 index 00000000000..ac16c416ffc --- /dev/null +++ b/extensions/matrix/src/env-vars.ts @@ -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(); + 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)); +} diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index 1e83b2df568..debbdf2d0a1 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -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; } diff --git a/extensions/matrix/src/matrix/account-config.ts b/extensions/matrix/src/matrix/account-config.ts new file mode 100644 index 00000000000..8f8c65b428e --- /dev/null +++ b/extensions/matrix/src/matrix/account-config.ts @@ -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> { + 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" + ); +} diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 56319b78b3a..45db29362ce 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -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", + }, }, }, }, diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index cdd09b219a4..6be14694814 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -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).accounts; - delete (merged as Record).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); -} diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 34d24b6dd39..d0d8b8810b3 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts new file mode 100644 index 00000000000..79c23eba62d --- /dev/null +++ b/extensions/matrix/src/matrix/actions/client.test.ts @@ -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"); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index f422e09a964..b4327434603 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -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( + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"]) => Promise, + mode: MatrixActionClientStopMode = "stop", +): Promise { + return await withResolvedRuntimeMatrixClient(opts, run, mode); } -export async function resolveActionClient( - opts: MatrixActionClientOpts = {}, -): Promise { - 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( + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"]) => Promise, +): Promise { + return await withResolvedActionClient({ ...opts, readiness: "started" }, run, "persist"); +} + +export async function withResolvedRoomAction( + roomId: string, + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"], resolvedRoom: string) => Promise, +): Promise { + return await withResolvedActionClient(opts, async (client) => { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await run(client, resolvedRoom); + }); } diff --git a/extensions/matrix/src/matrix/actions/devices.test.ts b/extensions/matrix/src/matrix/actions/devices.test.ts new file mode 100644 index 00000000000..17bf92e176d --- /dev/null +++ b/extensions/matrix/src/matrix/actions/devices.test.ts @@ -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, + }), + ]); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/devices.ts b/extensions/matrix/src/matrix/actions/devices.ts new file mode 100644 index 00000000000..ab6769cbfb8 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/devices.ts @@ -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()), + ); +} diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts new file mode 100644 index 00000000000..1ed2291d916 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -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>; + hydratedChunk?: Array>; + pollRoot?: Record; + pollRelations?: Array>; +}) { + const doRequest = vi.fn(async () => ({ + chunk: params.chunk, + start: "start-token", + end: "end-token", + })); + const hydrateEvents = vi.fn( + async (_roomId: string, _events: Array>) => + (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"); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index c32053a0e4f..728b5d1dfec 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -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(); + 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(); - } - } + }); } diff --git a/extensions/matrix/src/matrix/actions/pins.test.ts b/extensions/matrix/src/matrix/actions/pins.test.ts index 2b432c1a85c..5b621de5d63 100644 --- a/extensions/matrix/src/matrix/actions/pins.test.ts +++ b/extensions/matrix/src/matrix/actions/pins.test.ts @@ -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 = {}) { diff --git a/extensions/matrix/src/matrix/actions/pins.ts b/extensions/matrix/src/matrix/actions/pins.ts index 52baf69fd12..bcc3a2b287e 100644 --- a/extensions/matrix/src/matrix/actions/pins.ts +++ b/extensions/matrix/src/matrix/actions/pins.ts @@ -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( - roomId: string, - opts: MatrixActionClientOpts, - run: (client: ActionClient, resolvedRoom: string) => Promise, -): Promise { - 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( diff --git a/extensions/matrix/src/matrix/actions/polls.test.ts b/extensions/matrix/src/matrix/actions/polls.test.ts new file mode 100644 index 00000000000..a06b9087387 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/polls.test.ts @@ -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) { + 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(); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/polls.ts b/extensions/matrix/src/matrix/actions/polls.ts new file mode 100644 index 00000000000..2106a9cb1b7 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/polls.ts @@ -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, + }; + }); +} diff --git a/extensions/matrix/src/matrix/actions/profile.test.ts b/extensions/matrix/src/matrix/actions/profile.test.ts new file mode 100644 index 00000000000..3911d03268a --- /dev/null +++ b/extensions/matrix/src/matrix/actions/profile.test.ts @@ -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; + loadAvatarFromPath: (path: string, maxBytes: number) => Promise; + } + | 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, + }); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/profile.ts b/extensions/matrix/src/matrix/actions/profile.ts new file mode 100644 index 00000000000..d4ff78cc45d --- /dev/null +++ b/extensions/matrix/src/matrix/actions/profile.ts @@ -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 { + 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", + ); +} diff --git a/extensions/matrix/src/matrix/actions/reactions.test.ts b/extensions/matrix/src/matrix/actions/reactions.test.ts index aab161b54c0..2aa1eb9a471 100644 --- a/extensions/matrix/src/matrix/actions/reactions.test.ts +++ b/extensions/matrix/src/matrix/actions/reactions.test.ts @@ -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(); + }); }); diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts index e3d22c3fe02..6aa98dbf4d0 100644 --- a/extensions/matrix/src/matrix/actions/reactions.ts +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -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; -async function listReactionEvents( - client: NonNullable, +async function listMatrixReactionEvents( + client: ActionClient, roomId: string, messageId: string, limit: number, ): Promise { - 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 { - 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(); - 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(); - } - } + }); } diff --git a/extensions/matrix/src/matrix/actions/room.test.ts b/extensions/matrix/src/matrix/actions/room.test.ts new file mode 100644 index 00000000000..e87f1fd6441 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/room.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts index e1770c7bc8d..87684252cbe 100644 --- a/extensions/matrix/src/matrix/actions/room.ts +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -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(); - } - } + }); } diff --git a/extensions/matrix/src/matrix/actions/summary.test.ts b/extensions/matrix/src/matrix/actions/summary.test.ts new file mode 100644 index 00000000000..dcffd9757dd --- /dev/null +++ b/extensions/matrix/src/matrix/actions/summary.test.ts @@ -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(); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index 061829b0de5..69a3a76715d 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -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 diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 96694f4c743..8cc79959281 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -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; - 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 = { diff --git a/extensions/matrix/src/matrix/actions/verification.test.ts b/extensions/matrix/src/matrix/actions/verification.test.ts new file mode 100644 index 00000000000..32c12fe82b7 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/verification.test.ts @@ -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(); + }); +}); diff --git a/extensions/matrix/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts new file mode 100644 index 00000000000..0593ae768f8 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -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 { + 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, + }), + ); +} diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts index a38a419e670..990acb6f116 100644 --- a/extensions/matrix/src/matrix/active-client.ts +++ b/extensions/matrix/src/matrix/active-client.ts @@ -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(); +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(); -} diff --git a/extensions/matrix/src/matrix/backup-health.ts b/extensions/matrix/src/matrix/backup-health.ts new file mode 100644 index 00000000000..041de1f75c0 --- /dev/null +++ b/extensions/matrix/src/matrix/backup-health.ts @@ -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."; +} diff --git a/extensions/matrix/src/matrix/client-bootstrap.test.ts b/extensions/matrix/src/matrix/client-bootstrap.test.ts new file mode 100644 index 00000000000..c8a82519013 --- /dev/null +++ b/extensions/matrix/src/matrix/client-bootstrap.test.ts @@ -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"); + }); +}); diff --git a/extensions/matrix/src/matrix/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 9b8d4b7d7a2..47b679bb3a2 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -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; }; -type MatrixCryptoPrepare = { - prepare: (rooms?: string[]) => Promise; -}; +type MatrixRuntimeClientReadiness = "none" | "prepared" | "started"; +type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist"; -type MatrixBootstrapClient = Awaited>; +type MatrixResolvedClientHook = ( + client: MatrixClient, + context: { preparedByDefault: boolean }, +) => Promise | void; -export async function createPreparedMatrixClient(opts: { - auth: MatrixClientBootstrapAuth; +async function ensureResolvedClientReadiness(params: { + client: MatrixClient; + readiness?: MatrixRuntimeClientReadiness; + preparedByDefault: boolean; +}): Promise { + 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 { - 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 { + 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 { + 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 { + 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( + opts: { + client?: MatrixClient; + cfg?: CoreConfig; + timeoutMs?: number; + accountId?: string | null; + readiness?: MatrixRuntimeClientReadiness; + }, + run: (client: MatrixClient) => Promise, + stopMode: ResolvedRuntimeMatrixClientStopMode = "stop", +): Promise { + const resolved = await resolveRuntimeMatrixClientWithReadiness(opts); + try { + return await run(resolved.client); + } finally { + await stopResolvedRuntimeMatrixClient(resolved, stopMode); + } } diff --git a/extensions/matrix/src/matrix/client-resolver.test-helpers.ts b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts new file mode 100644 index 00000000000..ef90b3863dd --- /dev/null +++ b/extensions/matrix/src/matrix/client-resolver.test-helpers.ts @@ -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>; + releaseSharedClientInstanceMock: Mock<(...args: unknown[]) => Promise>; + 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; + auth?: Record; + 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; +} diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 69de112dbd5..fc89a4944e7 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -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 /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", + ); + }); }); diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 53abe1c3d5f..9fe0f667678 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index d5da7d4556d..8089d5c0e5a 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -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>(base: T, override: Partial): T { - const merged = { ...base, ...override } as Record; - // 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), ...(o as Record) }; - } - } - 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, + 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>, + 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 .', + ); + } + 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 { - 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, diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 2e1d4040612..5f5cb9d9db6 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -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 { - 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, + }); } diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts new file mode 100644 index 00000000000..85d61580a17 --- /dev/null +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -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((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: [], + }); + }); +}); diff --git a/extensions/matrix/src/matrix/client/file-sync-store.ts b/extensions/matrix/src/matrix/client/file-sync-store.ts new file mode 100644 index 00000000000..70c6ea5831a --- /dev/null +++ b/extensions/matrix/src/matrix/client/file-sync-store.ts @@ -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 = Promise.resolve(); + return async function withLock(fn: () => Promise): Promise { + const previous = lock; + let release: (() => void) | undefined; + lock = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await fn(); + } finally { + release?.(); + } + }; +} + +function isRecord(value: unknown): value is Record { + 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(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 | 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 { + return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null); + } + + override getSavedSyncToken(): Promise { + return Promise.resolve(this.savedSync?.nextBatch ?? null); + } + + override setSyncData(syncData: ISyncResponse): Promise { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 1f07d7ed542..a260aab4619 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -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); + }, + }; +} diff --git a/extensions/matrix/src/matrix/client/shared.test.ts b/extensions/matrix/src/matrix/client/shared.test.ts index 356e45a3542..c7e7d3e1a97 100644 --- a/extensions/matrix/src/matrix/client/shared.test.ts +++ b/extensions/matrix/src/matrix/client/shared.test.ts @@ -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): 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((_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((_resolve, reject) => { - rejectFirstStart = reject; - }); - const secondStart = new Promise(() => {}); - 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"); }); }); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index e12aa795d8c..dc3186d2682 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -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 | null; + leases: number; }; -// Support multiple accounts with separate clients const sharedClientStates = new Map(); const sharedClientPromises = new Map>(); -const sharedClientStartPromises = new Map>(); -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 { 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 }).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 { + 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 { - 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 { - // @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 { + 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 { + 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; } diff --git a/extensions/matrix/src/matrix/client/startup.test.ts b/extensions/matrix/src/matrix/client/startup.test.ts deleted file mode 100644 index c7135a012f5..00000000000 --- a/extensions/matrix/src/matrix/client/startup.test.ts +++ /dev/null @@ -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(() => {})), - }; - 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((_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(); - }); -}); diff --git a/extensions/matrix/src/matrix/client/startup.ts b/extensions/matrix/src/matrix/client/startup.ts deleted file mode 100644 index 4ae8cd64733..00000000000 --- a/extensions/matrix/src/matrix/client/startup.ts +++ /dev/null @@ -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; - graceMs?: number; - onError?: (err: unknown) => void; -}): Promise { - const graceMs = params.graceMs ?? MATRIX_CLIENT_STARTUP_GRACE_MS; - let startFailed = false; - let startError: unknown = undefined; - let startPromise: Promise; - 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; - } -} diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts new file mode 100644 index 00000000000..923f686df67 --- /dev/null +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -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(); + 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 = { + 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); + }); +}); diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 32f9768c68c..e6671de82c2 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -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; + 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 { 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 }); diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index ec1b3002bc7..6b189af6a95 100644 --- a/extensions/matrix/src/matrix/client/types.ts +++ b/extensions/matrix/src/matrix/client/types.ts @@ -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; }; diff --git a/extensions/matrix/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts new file mode 100644 index 00000000000..a5428e833e2 --- /dev/null +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts new file mode 100644 index 00000000000..452f9e38722 --- /dev/null +++ b/extensions/matrix/src/matrix/config-update.ts @@ -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, + 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, + key: keyof MatrixAccountPatch, + value: Array | 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 = { ...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, + }, + }, + }, + }; +} diff --git a/extensions/matrix/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts index 43a5096618e..eb05a1ed2d2 100644 --- a/extensions/matrix/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -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 = { + 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); }); }); diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index 8cd03e51e81..8efa77e45f4 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -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; + 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; - 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, env: NodeJS.ProcessEnv = process.env, accountId?: string | null, -): void { - const dir = resolveMatrixCredentialsDir(env); - fs.mkdirSync(dir, { recursive: true }); - +): Promise { 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 { 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; } diff --git a/extensions/matrix/src/matrix/deps.test.ts b/extensions/matrix/src/matrix/deps.test.ts index 7c5d17d1a95..c29d05d753f 100644 --- a/extensions/matrix/src/matrix/deps.test.ts +++ b/extensions/matrix/src/matrix/deps.test.ts @@ -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); diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 6b2ff09cbe7..a62a58bb65f 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -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; + 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 { - 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 { + 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 { + 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.", ); } } diff --git a/extensions/matrix/src/matrix/device-health.test.ts b/extensions/matrix/src/matrix/device-health.test.ts new file mode 100644 index 00000000000..8de5d825251 --- /dev/null +++ b/extensions/matrix/src/matrix/device-health.test.ts @@ -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" }), + ]); + }); +}); diff --git a/extensions/matrix/src/matrix/device-health.ts b/extensions/matrix/src/matrix/device-health.ts new file mode 100644 index 00000000000..6f0d4408a55 --- /dev/null +++ b/extensions/matrix/src/matrix/device-health.ts @@ -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), + }; +} diff --git a/extensions/matrix/src/matrix/direct-management.test.ts b/extensions/matrix/src/matrix/direct-management.test.ts new file mode 100644 index 00000000000..34407fef864 --- /dev/null +++ b/extensions/matrix/src/matrix/direct-management.test.ts @@ -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 { + 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")'); + }); +}); diff --git a/extensions/matrix/src/matrix/direct-management.ts b/extensions/matrix/src/matrix/direct-management.ts new file mode 100644 index 00000000000..2d27a68bf0f --- /dev/null +++ b/extensions/matrix/src/matrix/direct-management.ts @@ -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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/extensions/matrix/src/matrix/direct-room.ts b/extensions/matrix/src/matrix/direct-room.ts new file mode 100644 index 00000000000..a25004dbeb1 --- /dev/null +++ b/extensions/matrix/src/matrix/direct-room.ts @@ -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 { + 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 { + 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, + }); +} diff --git a/extensions/matrix/src/matrix/encryption-guidance.ts b/extensions/matrix/src/matrix/encryption-guidance.ts new file mode 100644 index 00000000000..7e6f7b9a3b1 --- /dev/null +++ b/extensions/matrix/src/matrix/encryption-guidance.ts @@ -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`; +} diff --git a/extensions/matrix/src/matrix/format.test.ts b/extensions/matrix/src/matrix/format.test.ts index 4538c2792e2..c929514ee17 100644 --- a/extensions/matrix/src/matrix/format.test.ts +++ b/extensions/matrix/src/matrix/format.test.ts @@ -14,6 +14,19 @@ describe("markdownToMatrixHtml", () => { expect(html).toContain('docs'); }); + 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("nope"); expect(html).toContain("<b>nope</b>"); diff --git a/extensions/matrix/src/matrix/format.ts b/extensions/matrix/src/matrix/format.ts index 65ba822bd65..31bddcc5292 100644 --- a/extensions/matrix/src/matrix/format.ts +++ b/extensions/matrix/src/matrix/format.ts @@ -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>[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 ?? ""); diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts deleted file mode 100644 index 7cd75d8a1ae..00000000000 --- a/extensions/matrix/src/matrix/index.ts +++ /dev/null @@ -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"; diff --git a/extensions/matrix/src/matrix/legacy-crypto-inspector.ts b/extensions/matrix/src/matrix/legacy-crypto-inspector.ts new file mode 100644 index 00000000000..7f22cd3379d --- /dev/null +++ b/extensions/matrix/src/matrix/legacy-crypto-inspector.ts @@ -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 { + 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(); + } +} diff --git a/extensions/matrix/src/matrix/media-text.ts b/extensions/matrix/src/matrix/media-text.ts new file mode 100644 index 00000000000..7ad195bf0fe --- /dev/null +++ b/extensions/matrix/src/matrix/media-text.ts @@ -0,0 +1,147 @@ +import path from "node:path"; +import type { + MatrixMessageAttachmentKind, + MatrixMessageAttachmentSummary, + MatrixMessageSummary, +} from "./actions/types.js"; + +const MATRIX_MEDIA_KINDS: Record = { + "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, +): 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, + }) ?? "" + ); +} diff --git a/extensions/matrix/src/matrix/monitor/access-policy.test.ts b/extensions/matrix/src/matrix/monitor/access-policy.test.ts deleted file mode 100644 index c4fe597b0ee..00000000000 --- a/extensions/matrix/src/matrix/monitor/access-policy.test.ts +++ /dev/null @@ -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, - }), - ); - }); -}); diff --git a/extensions/matrix/src/matrix/monitor/access-policy.ts b/extensions/matrix/src/matrix/monitor/access-policy.ts deleted file mode 100644 index 249051fbdc6..00000000000 --- a/extensions/matrix/src/matrix/monitor/access-policy.ts +++ /dev/null @@ -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; - senderId: string; - readStoreForDmPolicy: (provider: string, accountId: string) => Promise; -}) { - 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; - buildReplyText: (params: { code: string }) => string; - sendPairingReply: (text: string) => Promise; - onCreated: () => void; - onReplyError: (err: unknown) => void; - }) => Promise<{ created: boolean; code?: string }>; - sendPairingReply: (text: string) => Promise; - logVerboseMessage: (message: string) => void; -}): Promise { - 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 ", - ].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; -} diff --git a/extensions/matrix/src/matrix/monitor/access-state.test.ts b/extensions/matrix/src/matrix/monitor/access-state.test.ts new file mode 100644 index 00000000000..46f22e2c957 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/access-state.test.ts @@ -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 }); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/access-state.ts b/extensions/matrix/src/matrix/monitor/access-state.ts new file mode 100644 index 00000000000..8677b57d749 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/access-state.ts @@ -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; + storeAllowFrom: Array; + groupAllowFrom: Array; + roomUsers: Array; + 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, + }, + ], + }; +} diff --git a/extensions/matrix/src/matrix/monitor/ack-config.test.ts b/extensions/matrix/src/matrix/monitor/ack-config.test.ts new file mode 100644 index 00000000000..afba5890d33 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/ack-config.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/ack-config.ts b/extensions/matrix/src/matrix/monitor/ack-config.ts new file mode 100644 index 00000000000..c7d8b668f14 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/ack-config.ts @@ -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 }; +} diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts index 120db03f479..5d96f223874 100644 --- a/extensions/matrix/src/matrix/monitor/allowlist.ts +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -1,9 +1,8 @@ import { - compileAllowlist, normalizeStringEntries, - resolveCompiledAllowlistMatch, + resolveAllowlistMatchByCandidates, type AllowlistMatch, -} from "../../../runtime-api.js"; +} from "openclaw/plugin-sdk/matrix"; function normalizeAllowList(list?: Array) { return normalizeStringEntries(list); @@ -70,23 +69,27 @@ export function normalizeMatrixAllowList(list?: Array) { export type MatrixAllowListMatch = AllowlistMatch< "wildcard" | "id" | "prefixed-id" | "prefixed-user" >; -type MatrixAllowListSource = Exclude; + +type MatrixAllowListMatchSource = NonNullable; 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({ allowList, candidates }); } export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) { diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts new file mode 100644 index 00000000000..07dc83fe2a6 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts @@ -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; + +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 }).joinRoom, + resolveRoom: (client as unknown as { resolveRoom: ReturnType }).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"); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index bce1efc8b79..79dfc30f976 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -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; 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(); 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 => { + 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 => { + 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 { diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts new file mode 100644 index 00000000000..f2a146879f7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/config.test.ts @@ -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; + +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"], + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/config.ts b/extensions/matrix/src/matrix/monitor/config.ts new file mode 100644 index 00000000000..5a9086dd7ba --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/config.ts @@ -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; +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; + 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; + runtime: RuntimeEnv; + resolveTargets: ResolveMatrixTargetsFn; +}): Promise { + 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 { + 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(); + 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; + groupAllowFrom?: Array; + 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, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 6688f76e649..e7250683a97 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -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; -type DmMap = Record; -const brokenDmRoomId = "!broken-dm:example.org"; -const defaultBrokenDmMembers = ["@alice:example.org", "@bot:example.org"]; - -function createMockClient(opts: { - dmRooms?: DmMap; - membersByRoom?: Record; - stateEvents?: Record; - 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; + isDm: ReturnType; + }; + getJoinedRoomMembers: ReturnType; + __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); }); }); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index 43b935b35fa..c40967a05d6 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -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(map: Map, 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(); + const joinedMembersCache = new Map(); const ensureSelfUserId = async (): Promise => { if (cachedSelfUserId) { @@ -55,97 +55,66 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr } }; - const resolveMemberCount = async (roomId: string): Promise => { - const cached = memberCountCache.get(roomId); + const resolveJoinedMembers = async (roomId: string): Promise => { + 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 => { - 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 => { 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; }, }; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 73e96835ea3..0f8480424b5 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -1,186 +1,1118 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime, RuntimeLogger } from "../../../runtime-api.js"; +import { describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixVerificationSummary } from "../sdk/verification-manager.js"; import { registerMatrixMonitorEvents } from "./events.js"; import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; -const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void; +type FailedDecryptListener = (roomId: string, event: MatrixRawEvent, error: Error) => Promise; +type VerificationSummaryListener = (summary: MatrixVerificationSummary) => void; -vi.mock("../send.js", () => ({ - sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args), -})); +function getSentNoticeBody(sendMessage: ReturnType, index = 0): string { + const calls = sendMessage.mock.calls as unknown[][]; + const payload = (calls[index]?.[1] ?? {}) as { body?: string }; + return payload.body ?? ""; +} -describe("registerMatrixMonitorEvents", () => { - const roomId = "!room:example.org"; - - function makeEvent(overrides: Partial): MatrixRawEvent { - return { - event_id: "$event", - sender: "@alice:example.org", - type: "m.room.message", - origin_server_ts: 0, - content: {}, - ...overrides, +function createHarness(params?: { + cfg?: CoreConfig; + accountId?: string; + authEncryption?: boolean; + cryptoAvailable?: boolean; + selfUserId?: string; + selfUserIdError?: Error; + joinedMembersByRoom?: Record; + verifications?: Array<{ + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + updatedAt?: string; + completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; }; - } + }>; + ensureVerificationDmTracked?: () => Promise<{ + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + updatedAt?: string; + completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + } | null>; +}) { + const listeners = new Map void>(); + const onRoomMessage = vi.fn(async () => {}); + const listVerifications = vi.fn(async () => params?.verifications ?? []); + const ensureVerificationDmTracked = vi.fn( + params?.ensureVerificationDmTracked ?? (async () => null), + ); + const sendMessage = vi.fn(async () => "$notice"); + const invalidateRoom = vi.fn(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const formatNativeDependencyHint = vi.fn(() => "install hint"); + const logVerboseMessage = vi.fn(); + const client = { + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + return client; + }), + sendMessage, + getUserId: vi.fn(async () => { + if (params?.selfUserIdError) { + throw params.selfUserIdError; + } + return params?.selfUserId ?? "@bot:example.org"; + }), + getJoinedRoomMembers: vi.fn( + async (roomId: string) => + params?.joinedMembersByRoom?.[roomId] ?? ["@bot:example.org", "@alice:example.org"], + ), + getJoinedRooms: vi.fn(async () => Object.keys(params?.joinedMembersByRoom ?? {})), + ...(params?.cryptoAvailable === false + ? {} + : { + crypto: { + listVerifications, + ensureVerificationDmTracked, + }, + }), + } as unknown as MatrixClient; - beforeEach(() => { - sendReadReceiptMatrixMock.mockClear(); + registerMatrixMonitorEvents({ + cfg: params?.cfg ?? { channels: { matrix: {} } }, + client, + auth: { + accountId: params?.accountId ?? "default", + encryption: params?.authEncryption ?? true, + } as MatrixAuth, + directTracker: { + invalidateRoom, + }, + logVerboseMessage, + warnedEncryptedRooms: new Set(), + warnedCryptoMissingRooms: new Set(), + logger, + formatNativeDependencyHint, + onRoomMessage, }); - function createHarness(options?: { getUserId?: ReturnType }) { - const handlers = new Map void>(); - const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org"); - const client = { - on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { - handlers.set(event, handler); - }), - getUserId, - crypto: undefined, - } as unknown as MatrixClient; - - const onRoomMessage = vi.fn(); - const logVerboseMessage = vi.fn(); - const logger = { - warn: vi.fn(), - } as unknown as RuntimeLogger; - - registerMatrixMonitorEvents({ - client, - auth: { encryption: false } as MatrixAuth, - logVerboseMessage, - warnedEncryptedRooms: new Set(), - warnedCryptoMissingRooms: new Set(), - logger, - formatNativeDependencyHint: (() => - "") as PluginRuntime["system"]["formatNativeDependencyHint"], - onRoomMessage, - }); - - const roomMessageHandler = handlers.get("room.message"); - if (!roomMessageHandler) { - throw new Error("missing room.message handler"); - } - - return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage }; + const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined; + if (!roomEventListener) { + throw new Error("room.event listener was not registered"); } - async function expectForwardedWithoutReadReceipt(event: MatrixRawEvent) { - const { onRoomMessage, roomMessageHandler } = createHarness(); + return { + onRoomMessage, + sendMessage, + invalidateRoom, + roomEventListener, + listVerifications, + logger, + formatNativeDependencyHint, + logVerboseMessage, + roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, + failedDecryptListener: listeners.get("room.failed_decryption") as + | FailedDecryptListener + | undefined, + verificationSummaryListener: listeners.get("verification.summary") as + | VerificationSummaryListener + | undefined, + }; +} - roomMessageHandler(roomId, event); - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith(roomId, event); - }); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); - } +describe("registerMatrixMonitorEvents verification routing", () => { + it("does not repost historical verification completions during startup catch-up", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-14T13:10:00.000Z")); + try { + const { sendMessage, roomEventListener } = createHarness(); - it("sends read receipt immediately for non-self messages", async () => { - const { client, onRoomMessage, roomMessageHandler } = createHarness(); - const event = makeEvent({ - event_id: "$e1", - sender: "@alice:example.org", - }); - - roomMessageHandler("!room:example.org", event); - - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); - await vi.waitFor(() => { - expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client); - }); - }); - - it("does not send read receipts for self messages", async () => { - await expectForwardedWithoutReadReceipt( - makeEvent({ - event_id: "$e2", - sender: "@bot:example.org", - }), - ); - }); - - it("skips receipt when message lacks sender or event id", async () => { - await expectForwardedWithoutReadReceipt( - makeEvent({ + roomEventListener("!room:example.org", { + event_id: "$done-old", sender: "@alice:example.org", - event_id: "", - }), - ); + type: "m.key.verification.done", + origin_server_ts: Date.now() - 10 * 60 * 1000, + content: { + "m.relates_to": { event_id: "$req-old" }, + }, + }); + + await vi.runAllTimersAsync(); + expect(sendMessage).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); - it("caches self user id across messages", async () => { - const { getUserId, roomMessageHandler } = createHarness(); - const first = makeEvent({ event_id: "$e3", sender: "@alice:example.org" }); - const second = makeEvent({ event_id: "$e4", sender: "@bob:example.org" }); + it("still posts fresh verification completions", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-14T13:10:00.000Z")); + try { + const { sendMessage, roomEventListener } = createHarness(); - roomMessageHandler("!room:example.org", first); - roomMessageHandler("!room:example.org", second); + roomEventListener("!room:example.org", { + event_id: "$done-fresh", + sender: "@alice:example.org", + type: "m.key.verification.done", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-fresh" }, + }, + }); - await vi.waitFor(() => { - expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + expect(getSentNoticeBody(sendMessage)).toContain( + "Matrix verification completed with @alice:example.org.", + ); + } finally { + vi.useRealTimers(); + } + }); + + it("forwards reaction room events into the shared room handler", async () => { + const { onRoomMessage, sendMessage, roomEventListener } = createHarness(); + + roomEventListener("!room:example.org", { + event_id: "$reaction1", + sender: "@alice:example.org", + type: EventType.Reaction, + origin_server_ts: Date.now(), + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: "$msg1", + key: "👍", + }, + }, }); - expect(getUserId).toHaveBeenCalledTimes(1); - }); - - it("logs and continues when sending read receipt fails", async () => { - sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom")); - const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness(); - const event = makeEvent({ event_id: "$e5", sender: "@alice:example.org" }); - - roomMessageHandler("!room:example.org", event); await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); - expect(logVerboseMessage).toHaveBeenCalledWith( - expect.stringContaining("matrix: early read receipt failed"), + expect(onRoomMessage).toHaveBeenCalledWith( + "!room:example.org", + expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }), ); }); + expect(sendMessage).not.toHaveBeenCalled(); }); - it("skips read receipts if self-user lookup fails", async () => { - const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({ - getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")), - }); - const event = makeEvent({ event_id: "$e6", sender: "@alice:example.org" }); + it("invalidates direct-room membership cache on room member events", async () => { + const { invalidateRoom, roomEventListener } = createHarness(); - roomMessageHandler("!room:example.org", event); + roomEventListener("!room:example.org", { + event_id: "$member1", + sender: "@alice:example.org", + state_key: "@mallory:example.org", + type: EventType.RoomMember, + origin_server_ts: Date.now(), + content: { + membership: "join", + }, + }); + + expect(invalidateRoom).toHaveBeenCalledWith("!room:example.org"); + }); + + it("posts verification request notices directly into the room", async () => { + const { onRoomMessage, sendMessage, roomMessageListener } = createHarness(); + if (!roomMessageListener) { + throw new Error("room.message listener was not registered"); + } + roomMessageListener("!room:example.org", { + event_id: "$req1", + sender: "@alice:example.org", + type: EventType.RoomMessage, + origin_server_ts: Date.now(), + content: { + msgtype: "m.key.verification.request", + body: "verification request", + }, + }); await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); + expect(sendMessage).toHaveBeenCalledTimes(1); }); - expect(getUserId).toHaveBeenCalledTimes(1); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); + expect(onRoomMessage).not.toHaveBeenCalled(); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification request received from @alice:example.org."); + expect(body).toContain('Open "Verify by emoji"'); }); - it("skips duplicate listener registration for the same client", () => { - const handlers = new Map void>(); - const onMock = vi.fn((event: string, handler: (...args: unknown[]) => void) => { - handlers.set(event, handler); + it("posts ready-stage guidance for emoji verification", async () => { + const { sendMessage, roomEventListener } = createHarness(); + roomEventListener("!room:example.org", { + event_id: "$ready-1", + sender: "@alice:example.org", + type: "m.key.verification.ready", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-ready-1" }, + }, }); - const client = { - on: onMock, - getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - crypto: undefined, - } as unknown as MatrixClient; - const params = { - client, - auth: { encryption: false } as MatrixAuth, - logVerboseMessage: vi.fn(), - warnedEncryptedRooms: new Set(), - warnedCryptoMissingRooms: new Set(), - logger: { warn: vi.fn() } as unknown as RuntimeLogger, - formatNativeDependencyHint: (() => - "") as PluginRuntime["system"]["formatNativeDependencyHint"], - onRoomMessage: vi.fn(), - }; - registerMatrixMonitorEvents(params); - const initialCallCount = onMock.mock.calls.length; - registerMatrixMonitorEvents(params); - expect(onMock).toHaveBeenCalledTimes(initialCallCount); - expect(params.logVerboseMessage).toHaveBeenCalledWith( - "matrix: skipping duplicate listener registration for client", + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification is ready with @alice:example.org."); + expect(body).toContain('Choose "Verify by emoji"'); + }); + + it("posts SAS emoji/decimal details when verification summaries expose them", async () => { + const { sendMessage, roomEventListener, listVerifications } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-1", + transactionId: "$different-flow-id", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + otherUserId: "@alice:example.org", + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!dm:example.org", { + event_id: "$start2", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req2" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + }); + }); + + it("rehydrates an in-progress DM verification before resolving SAS notices", async () => { + const verifications: Array<{ + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + updatedAt?: string; + completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + }> = []; + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications, + ensureVerificationDmTracked: async () => { + verifications.splice(0, verifications.length, { + id: "verification-rehydrated", + transactionId: "$req-hydrated", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + phase: 3, + phaseName: "started", + pending: true, + sas: { + decimal: [2468, 1357, 9753], + emoji: [ + ["🔔", "Bell"], + ["📁", "Folder"], + ["🐴", "Horse"], + ], + }, + }); + return verifications[0] ?? null; + }, + }); + + roomEventListener("!dm:example.org", { + event_id: "$start-hydrated", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-hydrated" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 2468 1357 9753"))).toBe(true); + }); + }); + + it("posts SAS notices directly from verification summary updates", async () => { + const { sendMessage, verificationSummaryListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + verificationSummaryListener({ + id: "verification-direct", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification SAS with @alice:example.org:"); + expect(body).toContain("SAS decimal: 6158 1986 3513"); + }); + + it("posts SAS notices from summary updates using the room mapped by earlier flow events", async () => { + const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + roomEventListener("!dm:example.org", { + event_id: "$start-mapped", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + transaction_id: "txn-mapped-room", + "m.relates_to": { event_id: "$req-mapped" }, + }, + }); + + verificationSummaryListener({ + id: "verification-mapped", + transactionId: "txn-mapped-room", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(true); + }); + }); + + it("posts SAS notices from summary updates using the active strict DM when room mapping is missing", async () => { + const { sendMessage, verificationSummaryListener } = createHarness({ + joinedMembersByRoom: { + "!dm-active:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + verificationSummaryListener({ + id: "verification-unmapped", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [4321, 8765, 2109], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const roomId = (sendMessage.mock.calls[0]?.[0] ?? "") as string; + const body = getSentNoticeBody(sendMessage, 0); + expect(roomId).toBe("!dm-active:example.org"); + expect(body).toContain("SAS decimal: 4321 8765 2109"); + }); + + it("prefers the most recent verification DM over the canonical active DM for unmapped SAS summaries", async () => { + const { sendMessage, roomEventListener, verificationSummaryListener } = createHarness({ + joinedMembersByRoom: { + "!dm-active:example.org": ["@alice:example.org", "@bot:example.org"], + "!dm-current:example.org": ["@alice:example.org", "@bot:example.org"], + }, + }); + if (!verificationSummaryListener) { + throw new Error("verification.summary listener was not registered"); + } + + roomEventListener("!dm-current:example.org", { + event_id: "$start-current", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-current" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("Matrix verification started with"))).toBe(true); + }); + + verificationSummaryListener({ + id: "verification-current-room", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: true, + sas: { + decimal: [2468, 1357, 9753], + emoji: [ + ["🔔", "Bell"], + ["📁", "Folder"], + ["🐴", "Horse"], + ], + }, + hasReciprocateQr: false, + completed: false, + createdAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + updatedAt: new Date("2026-02-25T21:42:55.000Z").toISOString(), + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 2468 1357 9753"))).toBe(true); + }); + const calls = sendMessage.mock.calls as unknown[][]; + const sasCall = calls.find((call) => + String((call[1] as { body?: string } | undefined)?.body ?? "").includes( + "SAS decimal: 2468 1357 9753", + ), + ); + expect((sasCall?.[0] ?? "") as string).toBe("!dm-current:example.org"); + }); + + it("retries SAS notice lookup when start arrives before SAS payload is available", async () => { + vi.useFakeTimers(); + const verifications: Array<{ + id: string; + transactionId?: string; + otherUserId: string; + updatedAt?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + }> = [ + { + id: "verification-race", + transactionId: "$req-race", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + otherUserId: "@alice:example.org", + }, + ]; + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications, + }); + + try { + roomEventListener("!dm:example.org", { + event_id: "$start-race", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-race" }, + }, + }); + + await vi.advanceTimersByTimeAsync(500); + verifications[0] = { + ...verifications[0]!, + sas: { + decimal: [1234, 5678, 9012], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }; + await vi.advanceTimersByTimeAsync(500); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores verification notices in unrelated non-DM rooms", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!group:example.org": ["@alice:example.org", "@bot:example.org", "@ops:example.org"], + }, + verifications: [ + { + id: "verification-2", + transactionId: "$different-flow-id", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!group:example.org", { + event_id: "$start-group", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-group" }, + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(0); + }); + }); + + it("does not emit duplicate SAS notices for the same verification payload", async () => { + const { sendMessage, roomEventListener, listVerifications } = createHarness({ + verifications: [ + { + id: "verification-3", + transactionId: "$req3", + otherUserId: "@alice:example.org", + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + ], + }); + + roomEventListener("!room:example.org", { + event_id: "$start3", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req3" }, + }, + }); + await vi.waitFor(() => { + expect(sendMessage.mock.calls.length).toBeGreaterThan(0); + }); + + roomEventListener("!room:example.org", { + event_id: "$key3", + sender: "@alice:example.org", + type: "m.key.verification.key", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req3" }, + }, + }); + await vi.waitFor(() => { + expect(listVerifications).toHaveBeenCalledTimes(2); + }); + + const sasBodies = sendMessage.mock.calls + .map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? "")) + .filter((body) => body.includes("SAS emoji:")); + expect(sasBodies).toHaveLength(1); + }); + + it("ignores cancelled verification flows when DM fallback resolves SAS notices", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-old-cancelled", + transactionId: "$old-flow", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + phaseName: "cancelled", + phase: 4, + pending: false, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + { + id: "verification-new-active", + transactionId: "$different-flow-id", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:43:54.000Z").toISOString(), + phaseName: "started", + phase: 3, + pending: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!dm:example.org", { + event_id: "$start-active", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-active" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + }); + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + }); + + it("prefers the active verification for the current DM when multiple active summaries exist", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm-current:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-other-room", + roomId: "!dm-other:example.org", + transactionId: "$different-flow-other", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:44:54.000Z").toISOString(), + phaseName: "started", + phase: 3, + pending: true, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + { + id: "verification-current-room", + roomId: "!dm-current:example.org", + transactionId: "$different-flow-current", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:43:54.000Z").toISOString(), + phaseName: "started", + phase: 3, + pending: true, + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!dm-current:example.org", { + event_id: "$start-room-scoped", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req-room-scoped" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + }); + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS decimal: 1111 2222 3333"))).toBe(false); + }); + + it("does not emit SAS notices for cancelled verification events", async () => { + const { sendMessage, roomEventListener } = createHarness({ + joinedMembersByRoom: { + "!dm:example.org": ["@alice:example.org", "@bot:example.org"], + }, + verifications: [ + { + id: "verification-cancelled", + transactionId: "$req-cancelled", + otherUserId: "@alice:example.org", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + phaseName: "cancelled", + phase: 4, + pending: false, + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + ], + }); + + roomEventListener("!dm:example.org", { + event_id: "$cancelled-1", + sender: "@alice:example.org", + type: "m.key.verification.cancel", + origin_server_ts: Date.now(), + content: { + code: "m.mismatched_sas", + reason: "The SAS did not match.", + "m.relates_to": { event_id: "$req-cancelled" }, + }, + }); + + await vi.waitFor(() => { + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + const body = getSentNoticeBody(sendMessage, 0); + expect(body).toContain("Matrix verification cancelled by @alice:example.org"); + expect(body).not.toContain("SAS decimal:"); + }); + + it("warns once when encrypted events arrive without Matrix encryption enabled", () => { + const { logger, roomEventListener } = createHarness({ + authEncryption: false, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + roomEventListener("!room:example.org", { + event_id: "$enc2", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt", + { roomId: "!room:example.org" }, + ); + }); + + it("uses the active Matrix account path in encrypted-event warnings", () => { + const { logger, roomEventListener } = createHarness({ + accountId: "ops", + authEncryption: false, + cfg: { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encrypted event received without encryption enabled; set channels.matrix.accounts.ops.encryption=true and verify the device to decrypt", + { roomId: "!room:example.org" }, + ); + }); + + it("warns once when crypto bindings are unavailable for encrypted rooms", () => { + const { formatNativeDependencyHint, logger, roomEventListener } = createHarness({ + authEncryption: true, + cryptoAvailable: false, + }); + + roomEventListener("!room:example.org", { + event_id: "$enc1", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + roomEventListener("!room:example.org", { + event_id: "$enc2", + sender: "@alice:example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }); + + expect(formatNativeDependencyHint).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "matrix: encryption enabled but crypto is unavailable; install hint", + { roomId: "!room:example.org" }, + ); + }); + + it("adds self-device guidance when decrypt failures come from the same Matrix user", async () => { + const { logger, failedDecryptListener } = createHarness({ + accountId: "ops", + selfUserId: "@gumadeiras:matrix.example.org", + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + await failedDecryptListener( + "!room:example.org", + { + event_id: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + + expect(logger.warn).toHaveBeenNthCalledWith( + 1, + "Failed to decrypt message", + expect.objectContaining({ + roomId: "!room:example.org", + eventId: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + senderMatchesOwnUser: true, + }), + ); + expect(logger.warn).toHaveBeenNthCalledWith( + 2, + "matrix: failed to decrypt a message from this same Matrix user. This usually means another Matrix device did not share the room key, or another OpenClaw runtime is using the same account. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.", + { + roomId: "!room:example.org", + eventId: "$enc-self", + sender: "@gumadeiras:matrix.example.org", + }, + ); + }); + + it("does not add self-device guidance for decrypt failures from another sender", async () => { + const { logger, failedDecryptListener } = createHarness({ + accountId: "ops", + selfUserId: "@gumadeiras:matrix.example.org", + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + await failedDecryptListener( + "!room:example.org", + { + event_id: "$enc-other", + sender: "@alice:matrix.example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to decrypt message", + expect.objectContaining({ + roomId: "!room:example.org", + eventId: "$enc-other", + sender: "@alice:matrix.example.org", + senderMatchesOwnUser: false, + }), + ); + }); + + it("does not throw when getUserId fails during decrypt guidance lookup", async () => { + const { logger, logVerboseMessage, failedDecryptListener } = createHarness({ + accountId: "ops", + selfUserIdError: new Error("lookup failed"), + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + await expect( + failedDecryptListener( + "!room:example.org", + { + event_id: "$enc-lookup-fail", + sender: "@gumadeiras:matrix.example.org", + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now(), + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ), + ).resolves.toBeUndefined(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to decrypt message", + expect.objectContaining({ + roomId: "!room:example.org", + eventId: "$enc-lookup-fail", + senderMatchesOwnUser: false, + }), + ); + expect(logVerboseMessage).toHaveBeenCalledWith( + "matrix: failed resolving self user id for decrypt warning: Error: lookup failed", ); }); }); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 17e3c99c95d..42b3167ad6a 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,54 +1,42 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { PluginRuntime, RuntimeLogger } from "../../../runtime-api.js"; +import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig } from "../../types.js"; import type { MatrixAuth } from "../client.js"; -import { sendReadReceiptMatrix } from "../send.js"; +import { formatMatrixEncryptedEventDisabledWarning } from "../encryption-guidance.js"; +import type { MatrixClient } from "../sdk.js"; import type { MatrixRawEvent } from "./types.js"; import { EventType } from "./types.js"; +import { createMatrixVerificationEventRouter } from "./verification-events.js"; -const matrixMonitorListenerRegistry = (() => { - // Prevent duplicate listener registration when both bundled and extension - // paths attempt to start monitors against the same shared client. - const registeredClients = new WeakSet(); - return { - tryRegister(client: object): boolean { - if (registeredClients.has(client)) { - return false; - } - registeredClients.add(client); - return true; - }, - }; -})(); +function formatMatrixSelfDecryptionHint(accountId: string): string { + return ( + "matrix: failed to decrypt a message from this same Matrix user. " + + "This usually means another Matrix device did not share the room key, or another OpenClaw runtime is using the same account. " + + `Check 'openclaw matrix verify status --verbose --account ${accountId}' and 'openclaw matrix devices list --account ${accountId}'.` + ); +} -function createSelfUserIdResolver(client: Pick) { - let selfUserId: string | undefined; - let selfUserIdLookup: Promise | undefined; - - return async (): Promise => { - if (selfUserId) { - return selfUserId; - } - if (!selfUserIdLookup) { - selfUserIdLookup = client - .getUserId() - .then((userId) => { - selfUserId = userId; - return userId; - }) - .catch(() => undefined) - .finally(() => { - if (!selfUserId) { - selfUserIdLookup = undefined; - } - }); - } - return await selfUserIdLookup; - }; +async function resolveMatrixSelfUserId( + client: MatrixClient, + logVerboseMessage: (message: string) => void, +): Promise { + if (typeof client.getUserId !== "function") { + return null; + } + try { + return (await client.getUserId()) ?? null; + } catch (err) { + logVerboseMessage(`matrix: failed resolving self user id for decrypt warning: ${String(err)}`); + return null; + } } export function registerMatrixMonitorEvents(params: { + cfg: CoreConfig; client: MatrixClient; auth: MatrixAuth; + directTracker?: { + invalidateRoom: (roomId: string) => void; + }; logVerboseMessage: (message: string) => void; warnedEncryptedRooms: Set; warnedCryptoMissingRooms: Set; @@ -56,14 +44,11 @@ export function registerMatrixMonitorEvents(params: { formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; }): void { - if (!matrixMonitorListenerRegistry.tryRegister(params.client)) { - params.logVerboseMessage("matrix: skipping duplicate listener registration for client"); - return; - } - const { + cfg, client, auth, + directTracker, logVerboseMessage, warnedEncryptedRooms, warnedCryptoMissingRooms, @@ -71,26 +56,16 @@ export function registerMatrixMonitorEvents(params: { formatNativeDependencyHint, onRoomMessage, } = params; + const { routeVerificationEvent, routeVerificationSummary } = createMatrixVerificationEventRouter({ + client, + logVerboseMessage, + }); - const resolveSelfUserId = createSelfUserIdResolver(client); client.on("room.message", (roomId: string, event: MatrixRawEvent) => { - const eventId = event?.event_id; - const senderId = event?.sender; - if (eventId && senderId) { - void (async () => { - const currentSelfUserId = await resolveSelfUserId(); - if (!currentSelfUserId || senderId === currentSelfUserId) { - return; - } - await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => { - logVerboseMessage( - `matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`, - ); - }); - })(); + if (routeVerificationEvent(roomId, event)) { + return; } - - onRoomMessage(roomId, event); + void onRoomMessage(roomId, event); }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { @@ -108,18 +83,35 @@ export function registerMatrixMonitorEvents(params: { client.on( "room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => { + const selfUserId = await resolveMatrixSelfUserId(client, logVerboseMessage); + const sender = typeof event.sender === "string" ? event.sender : null; + const senderMatchesOwnUser = Boolean(selfUserId && sender && selfUserId === sender); logger.warn("Failed to decrypt message", { roomId, eventId: event.event_id, + sender, + senderMatchesOwnUser, error: error.message, }); + if (senderMatchesOwnUser) { + logger.warn(formatMatrixSelfDecryptionHint(auth.accountId), { + roomId, + eventId: event.event_id, + sender, + }); + } logVerboseMessage( `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, ); }, ); + client.on("verification.summary", (summary) => { + void routeVerificationSummary(summary); + }); + client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { + directTracker?.invalidateRoom(roomId); const eventId = event?.event_id ?? "unknown"; const sender = event?.sender ?? "unknown"; const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; @@ -129,6 +121,7 @@ export function registerMatrixMonitorEvents(params: { }); client.on("room.join", (roomId: string, event: MatrixRawEvent) => { + directTracker?.invalidateRoom(roomId); const eventId = event?.event_id ?? "unknown"; logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`); }); @@ -141,8 +134,7 @@ export function registerMatrixMonitorEvents(params: { ); if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { warnedEncryptedRooms.add(roomId); - const warning = - "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; + const warning = formatMatrixEncryptedEventDisabledWarning(cfg, auth.accountId); logger.warn(warning, { roomId }); } if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { @@ -158,11 +150,18 @@ export function registerMatrixMonitorEvents(params: { return; } if (eventType === EventType.RoomMember) { + directTracker?.invalidateRoom(roomId); const membership = (event?.content as { membership?: string } | undefined)?.membership; const stateKey = (event as { state_key?: string }).state_key ?? ""; logVerboseMessage( `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, ); } + if (eventType === EventType.Reaction) { + void onRoomMessage(roomId, event); + return; + } + + routeVerificationEvent(roomId, event); }); } diff --git a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts index 91ade71e41b..cbfaeac7a2e 100644 --- a/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.body-for-agent.test.ts @@ -1,213 +1,138 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { describe, expect, it, vi } from "vitest"; -import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; import { - createMatrixRoomMessageHandler, - resolveMatrixBaseRouteSession, - shouldOverrideMatrixDmToGroup, -} from "./handler.js"; -import { EventType, type MatrixRawEvent } from "./types.js"; + createMatrixHandlerTestHarness, + createMatrixTextMessageEvent, +} from "./handler.test-helpers.js"; +import type { MatrixRawEvent } from "./types.js"; -const dispatchReplyFromConfigWithSettledDispatcherMock = vi.hoisted(() => - vi.fn().mockResolvedValue({ - queuedFinal: false, - counts: { final: 0, partial: 0, tool: 0 }, - }), -); - -vi.mock("../../../runtime-api.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - dispatchReplyFromConfigWithSettledDispatcher: (...args: unknown[]) => - dispatchReplyFromConfigWithSettledDispatcherMock(...args), - }; -}); - -describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { - it("stores sender-labeled BodyForAgent for group thread messages", async () => { - const recordInboundSession = vi.fn().mockResolvedValue(undefined); - const formatInboundEnvelope = vi - .fn() - .mockImplementation((params: { senderLabel?: string; body: string }) => params.body); - const finalizeInboundContext = vi - .fn() - .mockImplementation((ctx: Record) => ctx); - - const core = { +describe("createMatrixRoomMessageHandler inbound body formatting", () => { + beforeEach(() => { + setMatrixRuntime({ channel: { - pairing: { - readAllowFromStore: vi.fn().mockResolvedValue([]), - upsertPairingRequest: vi.fn().mockResolvedValue(undefined), + mentions: { + matchesMentionPatterns: () => false, }, - routing: { - buildAgentSessionKey: vi - .fn() - .mockImplementation( - (params: { agentId: string; channel: string; peer?: { kind: string; id: string } }) => - `agent:${params.agentId}:${params.channel}:${params.peer?.kind ?? "direct"}:${params.peer?.id ?? "unknown"}`, - ), - resolveAgentRoute: vi.fn().mockReturnValue({ - agentId: "main", - accountId: undefined, - sessionKey: "agent:main:matrix:channel:!room:example.org", - mainSessionKey: "agent:main:main", - }), - }, - session: { - resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), - readSessionUpdatedAt: vi.fn().mockReturnValue(123), - recordInboundSession, - }, - reply: { - resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), - formatInboundEnvelope, - formatAgentEnvelope: vi - .fn() - .mockImplementation((params: { body: string }) => params.body), - finalizeInboundContext, - resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), - createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ - dispatcher: {}, - replyOptions: {}, - markDispatchIdle: vi.fn(), - }), - withReplyDispatcher: vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 0, partial: 0, tool: 0 } }), - }, - commands: { - shouldHandleTextCommands: vi.fn().mockReturnValue(true), - }, - text: { - hasControlCommand: vi.fn().mockReturnValue(false), - resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + media: { + saveMediaBuffer: vi.fn(), }, }, - system: { - enqueueSystemEvent: vi.fn(), + config: { + loadConfig: () => ({}), }, - } as unknown as PluginRuntime; - - const runtime = { - error: vi.fn(), - } as unknown as RuntimeEnv; - const logger = { - info: vi.fn(), - warn: vi.fn(), - } as unknown as RuntimeLogger; - const logVerboseMessage = vi.fn(); - - const client = { - getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), - } as unknown as MatrixClient; - - const handler = createMatrixRoomMessageHandler({ - client, - core, - cfg: {}, - runtime, - logger, - logVerboseMessage, - allowFrom: [], - roomsConfig: undefined, - mentionRegexes: [], - groupPolicy: "open", - replyToMode: "first", - threadReplies: "inbound", - dmEnabled: true, - dmPolicy: "open", - textLimit: 4000, - mediaMaxBytes: 5 * 1024 * 1024, - startupMs: Date.now(), - startupGraceMs: 60_000, - directTracker: { - isDirectMessage: vi.fn().mockResolvedValue(false), + state: { + resolveStateDir: () => "/tmp", }, - getRoomInfo: vi.fn().mockResolvedValue({ - name: "Dev Room", - canonicalAlias: "#dev:matrix.example.org", - altAliases: [], - }), - getMemberDisplayName: vi.fn().mockResolvedValue("Bu"), - accountId: undefined, - }); + } as never); + }); - const event = { - type: EventType.RoomMessage, - event_id: "$event1", - sender: "@bu:matrix.example.org", - origin_server_ts: Date.now(), - content: { - msgtype: "m.text", - body: "show me my commits", - "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, - "m.relates_to": { + it("records thread metadata for group thread messages", async () => { + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$thread-root", + sender: "@alice:example.org", + body: "Root topic", + }), + }, + isDirectMessage: false, + getMemberDisplayName: async (_roomId, userId) => + userId === "@alice:example.org" ? "Alice" : "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$reply1", + body: "@room follow up", + relatesTo: { rel_type: "m.thread", event_id: "$thread-root", + "m.in_reply_to": { event_id: "$thread-root" }, }, - }, - } as unknown as MatrixRawEvent; + mentions: { room: true }, + }), + ); - await handler("!room:example.org", event); - - expect(formatInboundEnvelope).toHaveBeenCalledWith( + expect(finalizeInboundContext).toHaveBeenCalledWith( expect.objectContaining({ - chatType: "channel", - senderLabel: "Bu (bu)", + MessageThreadId: "$thread-root", + ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic", }), ); expect(recordInboundSession).toHaveBeenCalledWith( expect.objectContaining({ - ctx: expect.objectContaining({ - ChatType: "thread", - BodyForAgent: "Bu (bu): show me my commits", - }), + sessionKey: "agent:ops:main", }), ); - expect(dispatchReplyFromConfigWithSettledDispatcherMock).toHaveBeenCalled(); }); - it("uses room-scoped session keys for DM rooms matched via parentPeer binding", () => { - const buildAgentSessionKey = vi - .fn() - .mockReturnValue("agent:main:matrix:channel:!dmroom:example.org"); - - const resolved = resolveMatrixBaseRouteSession({ - buildAgentSessionKey, - baseRoute: { - agentId: "main", - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - matchedBy: "binding.peer.parent", - }, - isDirectMessage: true, - roomId: "!dmroom:example.org", - accountId: undefined, - }); - - expect(buildAgentSessionKey).toHaveBeenCalledWith({ - agentId: "main", - channel: "matrix", - accountId: undefined, - peer: { kind: "channel", id: "!dmroom:example.org" }, - }); - expect(resolved).toEqual({ - sessionKey: "agent:main:matrix:channel:!dmroom:example.org", - lastRoutePolicy: "session", - }); - }); - - it("does not override DMs to groups for explicit allow:false room config", () => { - expect( - shouldOverrideMatrixDmToGroup({ + it("records formatted poll results for inbound poll response events", async () => { + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => ({ + event_id: "$poll", + sender: "@bot:example.org", + type: "m.poll.start", + origin_server_ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + }), + getRelations: async () => ({ + events: [ + { + type: "m.poll.response", + event_id: "$vote1", + sender: "@user:example.org", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + nextBatch: null, + prevBatch: null, + }), + } as unknown as Partial, isDirectMessage: true, - roomConfigInfo: { - config: { allow: false }, - allowed: false, - matchSource: "direct", - }, + getMemberDisplayName: async (_roomId, userId) => + userId === "@bot:example.org" ? "Bot" : "sender", + }); + + await handler("!room:example.org", { + type: "m.poll.response", + sender: "@user:example.org", + event_id: "$vote1", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + } as MatrixRawEvent); + + expect(finalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + RawBody: expect.stringMatching(/1\. Pizza \(1 vote\)[\s\S]*Total voters: 1/), }), - ).toBe(false); + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:ops:main", + }), + ); }); }); diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts new file mode 100644 index 00000000000..e1fc7e969ca --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -0,0 +1,239 @@ +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +const { downloadMatrixMediaMock } = vi.hoisted(() => ({ + downloadMatrixMediaMock: vi.fn(), +})); + +vi.mock("./media.js", () => ({ + downloadMatrixMedia: (...args: unknown[]) => downloadMatrixMediaMock(...args), +})); + +import { createMatrixRoomMessageHandler } from "./handler.js"; + +function createHandlerHarness() { + const recordInboundSession = vi.fn().mockResolvedValue(undefined); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as RuntimeLogger; + const runtime = { + error: vi.fn(), + } as unknown as RuntimeEnv; + const core = { + channel: { + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue(undefined), + buildPairingReply: vi.fn().mockReturnValue("pairing"), + }, + routing: { + resolveAgentRoute: vi.fn().mockReturnValue({ + agentId: "main", + accountId: undefined, + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + }), + }, + session: { + resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), + readSessionUpdatedAt: vi.fn().mockReturnValue(123), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), + formatAgentEnvelope: vi.fn().mockImplementation((params: { body: string }) => params.body), + finalizeInboundContext: vi.fn().mockImplementation((ctx: Record) => ctx), + createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }), + resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), + dispatchReplyFromConfig: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + }, + commands: { + shouldHandleTextCommands: vi.fn().mockReturnValue(true), + }, + text: { + hasControlCommand: vi.fn().mockReturnValue(false), + resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + }, + reactions: { + shouldAckReaction: vi.fn().mockReturnValue(false), + }, + }, + system: { + enqueueSystemEvent: vi.fn(), + }, + } as unknown as PluginRuntime; + + const client = { + getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), + } as unknown as MatrixClient; + + const handler = createMatrixRoomMessageHandler({ + client, + core, + cfg: {}, + accountId: "ops", + runtime, + logger, + logVerboseMessage: vi.fn(), + allowFrom: [], + groupAllowFrom: [], + roomsConfig: undefined, + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "first", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + startupMs: Date.now() - 120_000, + startupGraceMs: 60_000, + directTracker: { + isDirectMessage: vi.fn().mockResolvedValue(true), + }, + getRoomInfo: vi.fn().mockResolvedValue({ + name: "Media Room", + canonicalAlias: "#media:example.org", + altAliases: [], + }), + getMemberDisplayName: vi.fn().mockResolvedValue("Gum"), + needsRoomAliasesForConfig: false, + }); + + return { handler, recordInboundSession, logger, runtime }; +} + +function createImageEvent(content: Record): MatrixRawEvent { + return { + type: EventType.RoomMessage, + event_id: "$event1", + sender: "@gum:matrix.example.org", + origin_server_ts: Date.now(), + content: { + ...content, + "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + }, + } as MatrixRawEvent; +} + +describe("createMatrixRoomMessageHandler media failures", () => { + beforeEach(() => { + downloadMatrixMediaMock.mockReset(); + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as unknown as PluginRuntime); + }); + + it("replaces bare image filenames with an unavailable marker when unencrypted download fails", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("download failed")); + const { handler, recordInboundSession, logger, runtime } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "image.png", + url: "mxc://example/image", + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "[matrix image attachment unavailable]", + CommandBody: "[matrix image attachment unavailable]", + MediaPath: undefined, + }), + }), + ); + expect(logger.warn).toHaveBeenCalledWith( + "matrix media download failed", + expect.objectContaining({ + eventId: "$event1", + msgtype: "m.image", + encrypted: false, + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + }); + + it("replaces bare image filenames with an unavailable marker when encrypted download fails", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("decrypt failed")); + const { handler, recordInboundSession } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "photo.jpg", + file: { + url: "mxc://example/encrypted", + key: { kty: "oct", key_ops: ["encrypt"], alg: "A256CTR", k: "secret", ext: true }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "[matrix image attachment unavailable]", + CommandBody: "[matrix image attachment unavailable]", + MediaPath: undefined, + }), + }), + ); + }); + + it("preserves a real caption while marking the attachment unavailable", async () => { + downloadMatrixMediaMock.mockRejectedValue(new Error("download failed")); + const { handler, recordInboundSession } = createHandlerHarness(); + + await handler( + "!room:example.org", + createImageEvent({ + msgtype: "m.image", + body: "can you see this image?", + filename: "image.png", + url: "mxc://example/image", + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + RawBody: "can you see this image?\n\n[matrix image attachment unavailable]", + CommandBody: "can you see this image?\n\n[matrix image attachment unavailable]", + }), + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts new file mode 100644 index 00000000000..834b7e110a7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -0,0 +1,239 @@ +import type { RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { vi } from "vitest"; +import type { MatrixRoomConfig, ReplyToMode } from "../../types.js"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomMessageHandler, type MatrixMonitorHandlerParams } from "./handler.js"; +import { EventType, type MatrixRawEvent, type RoomMessageEventContent } from "./types.js"; + +const DEFAULT_ROUTE = { + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account" as const, +}; + +type MatrixHandlerTestHarnessOptions = { + accountId?: string; + cfg?: unknown; + client?: Partial; + runtime?: RuntimeEnv; + logger?: RuntimeLogger; + logVerboseMessage?: (message: string) => void; + allowFrom?: string[]; + groupAllowFrom?: string[]; + roomsConfig?: Record; + mentionRegexes?: MatrixMonitorHandlerParams["mentionRegexes"]; + groupPolicy?: "open" | "allowlist" | "disabled"; + replyToMode?: ReplyToMode; + threadReplies?: "off" | "inbound" | "always"; + dmEnabled?: boolean; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + textLimit?: number; + mediaMaxBytes?: number; + startupMs?: number; + startupGraceMs?: number; + dropPreStartupMessages?: boolean; + isDirectMessage?: boolean; + readAllowFromStore?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["readAllowFromStore"]; + upsertPairingRequest?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"]; + buildPairingReply?: () => string; + shouldHandleTextCommands?: () => boolean; + hasControlCommand?: () => boolean; + resolveMarkdownTableMode?: () => string; + resolveAgentRoute?: () => typeof DEFAULT_ROUTE; + resolveStorePath?: () => string; + readSessionUpdatedAt?: () => number | undefined; + recordInboundSession?: (...args: unknown[]) => Promise; + resolveEnvelopeFormatOptions?: () => Record; + formatAgentEnvelope?: ({ body }: { body: string }) => string; + finalizeInboundContext?: (ctx: unknown) => unknown; + createReplyDispatcherWithTyping?: () => { + dispatcher: Record; + replyOptions: Record; + markDispatchIdle: () => void; + }; + resolveHumanDelayConfig?: () => undefined; + dispatchReplyFromConfig?: () => Promise<{ + queuedFinal: boolean; + counts: { final: number; block: number; tool: number }; + }>; + shouldAckReaction?: () => boolean; + enqueueSystemEvent?: (...args: unknown[]) => void; + getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"]; + getMemberDisplayName?: MatrixMonitorHandlerParams["getMemberDisplayName"]; +}; + +type MatrixHandlerTestHarness = { + dispatchReplyFromConfig: () => Promise<{ + queuedFinal: boolean; + counts: { final: number; block: number; tool: number }; + }>; + enqueueSystemEvent: (...args: unknown[]) => void; + finalizeInboundContext: (ctx: unknown) => unknown; + handler: ReturnType; + readAllowFromStore: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["readAllowFromStore"]; + recordInboundSession: (...args: unknown[]) => Promise; + resolveAgentRoute: () => typeof DEFAULT_ROUTE; + upsertPairingRequest: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"]; +}; + +export function createMatrixHandlerTestHarness( + options: MatrixHandlerTestHarnessOptions = {}, +): MatrixHandlerTestHarness { + const readAllowFromStore = options.readAllowFromStore ?? vi.fn(async () => [] as string[]); + const upsertPairingRequest = + options.upsertPairingRequest ?? vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + const resolveAgentRoute = options.resolveAgentRoute ?? vi.fn(() => DEFAULT_ROUTE); + const recordInboundSession = options.recordInboundSession ?? vi.fn(async () => {}); + const finalizeInboundContext = options.finalizeInboundContext ?? vi.fn((ctx) => ctx); + const dispatchReplyFromConfig = + options.dispatchReplyFromConfig ?? + (async () => ({ + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + })); + const enqueueSystemEvent = options.enqueueSystemEvent ?? vi.fn(); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + getEvent: async () => ({ sender: "@bot:example.org" }), + ...options.client, + } as never, + core: { + channel: { + pairing: { + readAllowFromStore, + upsertPairingRequest, + buildPairingReply: options.buildPairingReply ?? (() => "pairing"), + }, + commands: { + shouldHandleTextCommands: options.shouldHandleTextCommands ?? (() => false), + }, + text: { + hasControlCommand: options.hasControlCommand ?? (() => false), + resolveMarkdownTableMode: options.resolveMarkdownTableMode ?? (() => "preserve"), + }, + routing: { + resolveAgentRoute, + }, + session: { + resolveStorePath: options.resolveStorePath ?? (() => "/tmp/session-store"), + readSessionUpdatedAt: options.readSessionUpdatedAt ?? (() => undefined), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})), + formatAgentEnvelope: + options.formatAgentEnvelope ?? (({ body }: { body: string }) => body), + finalizeInboundContext, + createReplyDispatcherWithTyping: + options.createReplyDispatcherWithTyping ?? + (() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + })), + resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined), + dispatchReplyFromConfig, + }, + reactions: { + shouldAckReaction: options.shouldAckReaction ?? (() => false), + }, + }, + system: { + enqueueSystemEvent, + }, + } as never, + cfg: (options.cfg ?? {}) as never, + accountId: options.accountId ?? "ops", + runtime: (options.runtime ?? + ({ + error: () => {}, + } as RuntimeEnv)) as RuntimeEnv, + logger: (options.logger ?? + ({ + info: () => {}, + warn: () => {}, + error: () => {}, + } as RuntimeLogger)) as RuntimeLogger, + logVerboseMessage: options.logVerboseMessage ?? (() => {}), + allowFrom: options.allowFrom ?? [], + groupAllowFrom: options.groupAllowFrom ?? [], + roomsConfig: options.roomsConfig, + mentionRegexes: options.mentionRegexes ?? [], + groupPolicy: options.groupPolicy ?? "open", + replyToMode: options.replyToMode ?? "off", + threadReplies: options.threadReplies ?? "inbound", + dmEnabled: options.dmEnabled ?? true, + dmPolicy: options.dmPolicy ?? "open", + textLimit: options.textLimit ?? 8_000, + mediaMaxBytes: options.mediaMaxBytes ?? 10_000_000, + startupMs: options.startupMs ?? 0, + startupGraceMs: options.startupGraceMs ?? 0, + dropPreStartupMessages: options.dropPreStartupMessages ?? true, + directTracker: { + isDirectMessage: async () => options.isDirectMessage ?? true, + }, + getRoomInfo: options.getRoomInfo ?? (async () => ({ altAliases: [] })), + getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), + needsRoomAliasesForConfig: false, + }); + + return { + dispatchReplyFromConfig, + enqueueSystemEvent, + finalizeInboundContext, + handler, + readAllowFromStore, + recordInboundSession, + resolveAgentRoute, + upsertPairingRequest, + }; +} + +export function createMatrixTextMessageEvent(params: { + eventId: string; + sender?: string; + body: string; + originServerTs?: number; + relatesTo?: RoomMessageEventContent["m.relates_to"]; + mentions?: RoomMessageEventContent["m.mentions"]; +}): MatrixRawEvent { + return { + type: EventType.RoomMessage, + sender: params.sender ?? "@user:example.org", + event_id: params.eventId, + origin_server_ts: params.originServerTs ?? Date.now(), + content: { + msgtype: "m.text", + body: params.body, + ...(params.relatesTo ? { "m.relates_to": params.relatesTo } : {}), + ...(params.mentions ? { "m.mentions": params.mentions } : {}), + }, + } as MatrixRawEvent; +} + +export function createMatrixReactionEvent(params: { + eventId: string; + targetEventId: string; + key: string; + sender?: string; + originServerTs?: number; +}): MatrixRawEvent { + return { + type: EventType.Reaction, + sender: params.sender ?? "@user:example.org", + event_id: params.eventId, + origin_server_ts: params.originServerTs ?? Date.now(), + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: params.targetEventId, + key: params.key, + }, + }, + } as MatrixRawEvent; +} diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts new file mode 100644 index 00000000000..2a627c0fc0e --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -0,0 +1,821 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + __testing as sessionBindingTesting, + registerSessionBindingAdapter, +} from "../../../../../src/infra/outbound/session-binding-service.js"; +import { setMatrixRuntime } from "../../runtime.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { + createMatrixHandlerTestHarness, + createMatrixReactionEvent, + createMatrixTextMessageEvent, +} from "./handler.test-helpers.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +const sendMessageMatrixMock = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })), +); + +vi.mock("../send.js", () => ({ + reactMatrixMessage: vi.fn(async () => {}), + sendMessageMatrix: sendMessageMatrixMock, + sendReadReceiptMatrix: vi.fn(async () => {}), + sendTypingMatrix: vi.fn(async () => {}), +})); + +beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as never); +}); + +function createReactionHarness(params?: { + cfg?: unknown; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom?: string[]; + storeAllowFrom?: string[]; + targetSender?: string; + isDirectMessage?: boolean; + senderName?: string; +}) { + return createMatrixHandlerTestHarness({ + cfg: params?.cfg, + dmPolicy: params?.dmPolicy, + allowFrom: params?.allowFrom, + readAllowFromStore: vi.fn(async () => params?.storeAllowFrom ?? []), + client: { + getEvent: async () => ({ sender: params?.targetSender ?? "@bot:example.org" }), + }, + isDirectMessage: params?.isDirectMessage, + getMemberDisplayName: async () => params?.senderName ?? "sender", + }); +} + +describe("matrix monitor handler pairing account scope", () => { + it("caches account-scoped allowFrom store reads on hot path", async () => { + const readAllowFromStore = vi.fn(async () => [] as string[]); + sendMessageMatrixMock.mockClear(); + + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + dmPolicy: "pairing", + buildPairingReply: () => "pairing", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event1", + body: "@room hello", + mentions: { room: true }, + }), + ); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event2", + body: "@room hello again", + mentions: { room: true }, + }), + ); + + expect(readAllowFromStore).toHaveBeenCalledTimes(1); + }); + + it("refreshes the account-scoped allowFrom cache after its ttl expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + try { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + dmPolicy: "pairing", + buildPairingReply: () => "pairing", + }); + + const makeEvent = (id: string): MatrixRawEvent => + createMatrixTextMessageEvent({ + eventId: id, + body: "@room hello", + mentions: { room: true }, + }); + + await handler("!room:example.org", makeEvent("$event1")); + await handler("!room:example.org", makeEvent("$event2")); + expect(readAllowFromStore).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30_001); + await handler("!room:example.org", makeEvent("$event3")); + + expect(readAllowFromStore).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("sends pairing reminders for pending requests with cooldown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + try { + const readAllowFromStore = vi.fn(async () => [] as string[]); + sendMessageMatrixMock.mockClear(); + + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + dmPolicy: "pairing", + buildPairingReply: () => "Pairing code: ABCDEFGH", + isDirectMessage: true, + getMemberDisplayName: async () => "sender", + }); + + const makeEvent = (id: string): MatrixRawEvent => + createMatrixTextMessageEvent({ + eventId: id, + body: "hello", + mentions: { room: true }, + }); + + await handler("!room:example.org", makeEvent("$event1")); + await handler("!room:example.org", makeEvent("$event2")); + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing request is still pending approval.", + ); + + await vi.advanceTimersByTimeAsync(5 * 60_000 + 1); + await handler("!room:example.org", makeEvent("$event3")); + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("uses account-scoped pairing store reads and upserts for dm pairing", async () => { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + + const { handler } = createMatrixHandlerTestHarness({ + readAllowFromStore, + upsertPairingRequest, + dmPolicy: "pairing", + isDirectMessage: true, + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event1", + body: "hello", + mentions: { room: true }, + }), + ); + + expect(readAllowFromStore).toHaveBeenCalledWith({ + channel: "matrix", + env: process.env, + accountId: "ops", + }); + expect(upsertPairingRequest).toHaveBeenCalledWith({ + channel: "matrix", + id: "@user:example.org", + accountId: "ops", + meta: { name: "sender" }, + }); + }); + + it("passes accountId into route resolution for inbound dm messages", async () => { + const resolveAgentRoute = vi.fn(() => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account" as const, + })); + + const { handler } = createMatrixHandlerTestHarness({ + resolveAgentRoute, + isDirectMessage: true, + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event2", + body: "hello", + mentions: { room: true }, + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "matrix", + accountId: "ops", + }), + ); + }); + + it("does not enqueue delivered text messages into system events", async () => { + const dispatchReplyFromConfig = vi.fn(async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + })); + const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({ + dispatchReplyFromConfig, + isDirectMessage: true, + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$event-system-preview", + body: "hello from matrix", + mentions: { room: true }, + }), + ); + + expect(dispatchReplyFromConfig).toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("drops forged metadata-only mentions before agent routing", async () => { + const { handler, recordInboundSession, resolveAgentRoute } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + mentionRegexes: [/@bot/i], + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$spoofed-mention", + body: "hello there", + mentions: { user_ids: ["@bot:example.org"] }, + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + expect(recordInboundSession).not.toHaveBeenCalled(); + }); + + it("skips media downloads for unmentioned group media messages", async () => { + const downloadContent = vi.fn(async () => Buffer.from("image")); + const getMemberDisplayName = vi.fn(async () => "sender"); + const getRoomInfo = vi.fn(async () => ({ altAliases: [] })); + const { handler, resolveAgentRoute } = createMatrixHandlerTestHarness({ + client: { + downloadContent, + }, + isDirectMessage: false, + mentionRegexes: [/@bot/i], + getMemberDisplayName, + getRoomInfo, + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: "$media1", + origin_server_ts: Date.now(), + content: { + msgtype: "m.image", + body: "", + url: "mxc://example.org/media", + info: { + mimetype: "image/png", + size: 5, + }, + }, + } as MatrixRawEvent); + + expect(downloadContent).not.toHaveBeenCalled(); + expect(getMemberDisplayName).not.toHaveBeenCalled(); + expect(getRoomInfo).not.toHaveBeenCalled(); + expect(resolveAgentRoute).not.toHaveBeenCalled(); + }); + + it("skips poll snapshot fetches for unmentioned group poll responses", async () => { + const getEvent = vi.fn(async () => ({ + event_id: "$poll", + sender: "@user:example.org", + type: "m.poll.start", + origin_server_ts: Date.now(), + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + })); + const getRelations = vi.fn(async () => ({ + events: [], + nextBatch: null, + prevBatch: null, + })); + const getMemberDisplayName = vi.fn(async () => "sender"); + const getRoomInfo = vi.fn(async () => ({ altAliases: [] })); + const { handler, resolveAgentRoute } = createMatrixHandlerTestHarness({ + client: { + getEvent, + getRelations, + }, + isDirectMessage: false, + mentionRegexes: [/@bot/i], + getMemberDisplayName, + getRoomInfo, + }); + + await handler("!room:example.org", { + type: "m.poll.response", + sender: "@user:example.org", + event_id: "$poll-response-1", + origin_server_ts: Date.now(), + content: { + "m.poll.response": { + answers: ["a1"], + }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }, + } as MatrixRawEvent); + + expect(getEvent).not.toHaveBeenCalled(); + expect(getRelations).not.toHaveBeenCalled(); + expect(getMemberDisplayName).not.toHaveBeenCalled(); + expect(getRoomInfo).not.toHaveBeenCalled(); + expect(resolveAgentRoute).not.toHaveBeenCalled(); + }); + + it("records thread starter context for inbound thread replies", async () => { + const { handler, finalizeInboundContext, recordInboundSession } = + createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$root", + sender: "@alice:example.org", + body: "Root topic", + }), + }, + isDirectMessage: false, + getMemberDisplayName: async (_roomId, userId) => + userId === "@alice:example.org" ? "Alice" : "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$reply1", + body: "@room follow up", + relatesTo: { + rel_type: "m.thread", + event_id: "$root", + "m.in_reply_to": { event_id: "$root" }, + }, + mentions: { room: true }, + }), + ); + + expect(finalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + MessageThreadId: "$root", + ThreadStarterBody: "Matrix thread root $root from Alice:\nRoot topic", + }), + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:ops:main", + }), + ); + }); + + it("uses stable room ids instead of room-declared aliases in group context", async () => { + const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({ + isDirectMessage: false, + getRoomInfo: async () => ({ + name: "Ops Room", + canonicalAlias: "#spoofed:example.org", + altAliases: ["#alt:example.org"], + }), + getMemberDisplayName: async () => "sender", + dispatchReplyFromConfig: async () => ({ + queuedFinal: false, + counts: { final: 0, block: 0, tool: 0 }, + }), + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$group1", + body: "@room hello", + mentions: { room: true }, + }), + ); + + const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0]; + expect(finalized).toEqual( + expect.objectContaining({ + GroupSubject: "Ops Room", + GroupId: "!room:example.org", + }), + ); + expect(finalized).not.toHaveProperty("GroupChannel"); + }); + + it("routes bound Matrix threads to the target session key", async () => { + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "ops", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "$root" + ? { + bindingId: "ops:!room:example:$root", + targetSessionKey: "agent:bound:session-1", + targetKind: "session", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + }, + } + : null, + touch: vi.fn(), + }); + const { handler, recordInboundSession } = createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$root", + sender: "@alice:example.org", + body: "Root topic", + }), + }, + isDirectMessage: false, + finalizeInboundContext: (ctx: unknown) => ctx, + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example", + createMatrixTextMessageEvent({ + eventId: "$reply1", + body: "@room follow up", + relatesTo: { + rel_type: "m.thread", + event_id: "$root", + "m.in_reply_to": { event_id: "$root" }, + }, + mentions: { room: true }, + }), + ); + + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:bound:session-1", + }), + ); + }); + + it("does not enqueue system events for delivered text replies", async () => { + const enqueueSystemEvent = vi.fn(); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + } as never, + core: { + channel: { + pairing: { + readAllowFromStore: async () => [] as string[], + upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), + buildPairingReply: () => "pairing", + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + resolveMarkdownTableMode: () => "preserve", + }, + routing: { + resolveAgentRoute: () => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account", + }), + }, + session: { + resolveStorePath: () => "/tmp/session-store", + readSessionUpdatedAt: () => undefined, + recordInboundSession: vi.fn(async () => {}), + }, + reply: { + resolveEnvelopeFormatOptions: () => ({}), + formatAgentEnvelope: ({ body }: { body: string }) => body, + finalizeInboundContext: (ctx: unknown) => ctx, + createReplyDispatcherWithTyping: () => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: () => {}, + }), + resolveHumanDelayConfig: () => undefined, + dispatchReplyFromConfig: async () => ({ + queuedFinal: true, + counts: { final: 1, block: 0, tool: 0 }, + }), + }, + reactions: { + shouldAckReaction: () => false, + }, + }, + system: { + enqueueSystemEvent, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: { + error: () => {}, + } as never, + logger: { + info: () => {}, + warn: () => {}, + } as never, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => false, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async () => "sender", + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$message1", + sender: "@user:example.org", + body: "hello there", + mentions: { room: true }, + }) as MatrixRawEvent, + ); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("enqueues system events for reactions on bot-authored messages", async () => { + const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness(); + + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction1", + targetEventId: "$msg1", + key: "👍", + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "matrix", + accountId: "ops", + }), + ); + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Matrix reaction added: 👍 by sender on msg $msg1", + { + sessionKey: "agent:ops:main", + contextKey: "matrix:reaction:add:!room:example.org:$msg1:@user:example.org:👍", + }, + ); + }); + + it("routes reaction notifications for bound thread messages to the bound session", async () => { + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "ops", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "$root" + ? { + bindingId: "ops:!room:example.org:$root", + targetSessionKey: "agent:bound:session-1", + targetKind: "session", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example.org", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + }, + } + : null, + touch: vi.fn(), + }); + + const { handler, enqueueSystemEvent } = createMatrixHandlerTestHarness({ + client: { + getEvent: async () => + createMatrixTextMessageEvent({ + eventId: "$reply1", + sender: "@bot:example.org", + body: "follow up", + relatesTo: { + rel_type: "m.thread", + event_id: "$root", + "m.in_reply_to": { event_id: "$root" }, + }, + }), + }, + isDirectMessage: false, + }); + + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction-thread", + targetEventId: "$reply1", + key: "🎯", + }), + ); + + expect(enqueueSystemEvent).toHaveBeenCalledWith( + "Matrix reaction added: 🎯 by sender on msg $reply1", + { + sessionKey: "agent:bound:session-1", + contextKey: "matrix:reaction:add:!room:example.org:$reply1:@user:example.org:🎯", + }, + ); + }); + + it("ignores reactions that do not target bot-authored messages", async () => { + const { handler, enqueueSystemEvent, resolveAgentRoute } = createReactionHarness({ + targetSender: "@other:example.org", + }); + + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction2", + targetEventId: "$msg2", + key: "👀", + }), + ); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + expect(resolveAgentRoute).not.toHaveBeenCalled(); + }); + + it("does not create pairing requests for unauthorized dm reactions", async () => { + const { handler, enqueueSystemEvent, upsertPairingRequest } = createReactionHarness({ + dmPolicy: "pairing", + }); + + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction3", + targetEventId: "$msg3", + key: "🔥", + }), + ); + + expect(upsertPairingRequest).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("honors account-scoped reaction notification overrides", async () => { + const { handler, enqueueSystemEvent } = createReactionHarness({ + cfg: { + channels: { + matrix: { + reactionNotifications: "own", + accounts: { + ops: { + reactionNotifications: "off", + }, + }, + }, + }, + }, + }); + + await handler( + "!room:example.org", + createMatrixReactionEvent({ + eventId: "$reaction4", + targetEventId: "$msg4", + key: "✅", + }), + ); + + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("drops pre-startup dm messages on cold start", async () => { + const resolveAgentRoute = vi.fn(() => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account" as const, + })); + const { handler } = createMatrixHandlerTestHarness({ + resolveAgentRoute, + isDirectMessage: true, + startupMs: 1_000, + startupGraceMs: 0, + dropPreStartupMessages: true, + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$old-cold-start", + body: "hello", + originServerTs: 999, + }), + ); + + expect(resolveAgentRoute).not.toHaveBeenCalled(); + }); + + it("replays pre-startup dm messages when persisted sync state exists", async () => { + const resolveAgentRoute = vi.fn(() => ({ + agentId: "ops", + channel: "matrix", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account" as const, + })); + const { handler } = createMatrixHandlerTestHarness({ + resolveAgentRoute, + isDirectMessage: true, + startupMs: 1_000, + startupGraceMs: 0, + dropPreStartupMessages: false, + }); + + await handler( + "!room:example.org", + createMatrixTextMessageEvent({ + eventId: "$old-resume", + body: "hello", + originServerTs: 999, + }), + ); + + expect(resolveAgentRoute).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts new file mode 100644 index 00000000000..7dfbcebe401 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -0,0 +1,159 @@ +import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { EventType, type MatrixRawEvent } from "./types.js"; + +describe("createMatrixRoomMessageHandler thread root media", () => { + it("keeps image-only thread roots visible via attachment markers", async () => { + setMatrixRuntime({ + channel: { + mentions: { + matchesMentionPatterns: (text: string, patterns: RegExp[]) => + patterns.some((pattern) => pattern.test(text)), + }, + media: { + saveMediaBuffer: vi.fn(), + }, + }, + config: { + loadConfig: () => ({}), + }, + state: { + resolveStateDir: () => "/tmp", + }, + } as unknown as PluginRuntime); + + const recordInboundSession = vi.fn().mockResolvedValue(undefined); + const formatAgentEnvelope = vi + .fn() + .mockImplementation((params: { body: string }) => params.body); + + const core = { + channel: { + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue(undefined), + buildPairingReply: vi.fn().mockReturnValue("pairing"), + }, + routing: { + resolveAgentRoute: vi.fn().mockReturnValue({ + agentId: "main", + accountId: undefined, + sessionKey: "agent:main:matrix:channel:!room:example.org", + mainSessionKey: "agent:main:main", + }), + }, + session: { + resolveStorePath: vi.fn().mockReturnValue("/tmp/openclaw-test-session.json"), + readSessionUpdatedAt: vi.fn().mockReturnValue(undefined), + recordInboundSession, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn().mockReturnValue({}), + formatAgentEnvelope, + finalizeInboundContext: vi.fn().mockImplementation((ctx: Record) => ctx), + createReplyDispatcherWithTyping: vi.fn().mockReturnValue({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + }), + resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined), + dispatchReplyFromConfig: vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }), + }, + commands: { + shouldHandleTextCommands: vi.fn().mockReturnValue(true), + }, + text: { + hasControlCommand: vi.fn().mockReturnValue(false), + resolveMarkdownTableMode: vi.fn().mockReturnValue("code"), + }, + reactions: { + shouldAckReaction: vi.fn().mockReturnValue(false), + }, + }, + system: { + enqueueSystemEvent: vi.fn(), + }, + } as unknown as PluginRuntime; + + const client = { + getUserId: vi.fn().mockResolvedValue("@bot:matrix.example.org"), + getEvent: vi.fn().mockResolvedValue({ + event_id: "$thread-root", + sender: "@gum:matrix.example.org", + type: "m.room.message", + origin_server_ts: 123, + content: { + msgtype: "m.image", + body: "photo.jpg", + }, + }), + } as unknown as MatrixClient; + + const handler = createMatrixRoomMessageHandler({ + client, + core, + cfg: {}, + accountId: "ops", + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as unknown as RuntimeLogger, + logVerboseMessage: vi.fn(), + allowFrom: [], + groupAllowFrom: [], + roomsConfig: undefined, + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "first", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 4000, + mediaMaxBytes: 5 * 1024 * 1024, + startupMs: Date.now() - 120_000, + startupGraceMs: 60_000, + directTracker: { + isDirectMessage: vi.fn().mockResolvedValue(true), + }, + getRoomInfo: vi.fn().mockResolvedValue({ + name: "Media Room", + canonicalAlias: "#media:example.org", + altAliases: [], + }), + getMemberDisplayName: vi.fn().mockResolvedValue("Gum"), + needsRoomAliasesForConfig: false, + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + event_id: "$reply", + sender: "@bu:matrix.example.org", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "replying", + "m.mentions": { user_ids: ["@bot:matrix.example.org"] }, + "m.relates_to": { + rel_type: "m.thread", + event_id: "$thread-root", + }, + }, + } as MatrixRawEvent); + + expect(formatAgentEnvelope).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("replying"), + }), + ); + expect(recordInboundSession).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + ThreadStarterBody: expect.stringContaining("[matrix image attachment]"), + }), + }), + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index a0cd8148765..066c9cdf39a 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,57 +1,63 @@ -import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { - DEFAULT_ACCOUNT_ID, - createChannelPairingController, - createChannelReplyPipeline, - dispatchReplyFromConfigWithSettledDispatcher, - evaluateGroupRouteAccessForPolicy, + createReplyPrefixOptions, + createTypingCallbacks, + ensureConfiguredAcpBindingReady, formatAllowlistMatchMeta, + getAgentScopedMediaLocalRoots, logInboundDrop, logTypingFailure, - resolveInboundSessionEnvelopeContext, resolveControlCommandGate, type PluginRuntime, + type ReplyPayload, type RuntimeEnv, type RuntimeLogger, -} from "../../../runtime-api.js"; +} from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; -import { fetchEventSummary } from "../actions/summary.js"; +import { formatMatrixMediaUnavailableText } from "../media-text.js"; +import { fetchMatrixPollSnapshot } from "../poll-summary.js"; import { formatPollAsText, + isPollEventType, isPollStartType, parsePollStartContent, - type PollStartContent, } from "../poll-types.js"; -import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; -import { enforceMatrixDirectMessageAccess, resolveMatrixAccessState } from "./access-policy.js"; +import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; import { - normalizeMatrixAllowList, - resolveMatrixAllowListMatch, - resolveMatrixAllowListMatches, -} from "./allowlist.js"; -import { - resolveMatrixBodyForAgent, - resolveMatrixInboundSenderLabel, - resolveMatrixSenderUsername, -} from "./inbound-body.js"; + reactMatrixMessage, + sendMessageMatrix, + sendReadReceiptMatrix, + sendTypingMatrix, +} from "../send.js"; +import { resolveMatrixMonitorAccessState } from "./access-state.js"; +import { resolveMatrixAckReactionConfig } from "./ack-config.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions } from "./mentions.js"; +import { handleInboundMatrixReaction } from "./reaction-events.js"; import { deliverMatrixReplies } from "./replies.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; +import { resolveMatrixInboundRoute } from "./route.js"; +import { createMatrixThreadContextResolver } from "./thread-context.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; import { EventType, RelationType } from "./types.js"; +import { isMatrixVerificationRoomMessage } from "./verification-utils.js"; + +const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000; +const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000; +const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512; export type MatrixMonitorHandlerParams = { client: MatrixClient; core: PluginRuntime; cfg: CoreConfig; + accountId: string; runtime: RuntimeEnv; logger: RuntimeLogger; logVerboseMessage: (message: string) => void; allowFrom: string[]; - roomsConfig: Record | undefined; + groupAllowFrom?: string[]; + roomsConfig?: Record; mentionRegexes: ReturnType; groupPolicy: "open" | "allowlist" | "disabled"; replyToMode: ReplyToMode; @@ -62,6 +68,7 @@ export type MatrixMonitorHandlerParams = { mediaMaxBytes: number; startupMs: number; startupGraceMs: number; + dropPreStartupMessages: boolean; directTracker: { isDirectMessage: (params: { roomId: string; @@ -71,59 +78,51 @@ export type MatrixMonitorHandlerParams = { }; getRoomInfo: ( roomId: string, + opts?: { includeAliases?: boolean }, ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; getMemberDisplayName: (roomId: string, userId: string) => Promise; - accountId?: string | null; + needsRoomAliasesForConfig: boolean; }; -export function resolveMatrixBaseRouteSession(params: { - buildAgentSessionKey: (params: { - agentId: string; - channel: string; - accountId?: string | null; - peer?: { kind: "direct" | "channel"; id: string } | null; - }) => string; - baseRoute: { - agentId: string; - sessionKey: string; - mainSessionKey: string; - matchedBy?: string; - }; - isDirectMessage: boolean; - roomId: string; - accountId?: string | null; -}): { sessionKey: string; lastRoutePolicy: "main" | "session" } { - const sessionKey = - params.isDirectMessage && params.baseRoute.matchedBy === "binding.peer.parent" - ? params.buildAgentSessionKey({ - agentId: params.baseRoute.agentId, - channel: "matrix", - accountId: params.accountId, - peer: { kind: "channel", id: params.roomId }, - }) - : params.baseRoute.sessionKey; - return { - sessionKey, - lastRoutePolicy: sessionKey === params.baseRoute.mainSessionKey ? "main" : "session", - }; +function resolveMatrixMentionPrecheckText(params: { + eventType: string; + content: RoomMessageEventContent; + locationText?: string | null; +}): string { + if (params.locationText?.trim()) { + return params.locationText.trim(); + } + if (typeof params.content.body === "string" && params.content.body.trim()) { + return params.content.body.trim(); + } + if (isPollStartType(params.eventType)) { + const parsed = parsePollStartContent(params.content as never); + if (parsed) { + return formatPollAsText(parsed); + } + } + return ""; } -export function shouldOverrideMatrixDmToGroup(params: { - isDirectMessage: boolean; - roomConfigInfo?: - | { - config?: MatrixRoomConfig; - allowed: boolean; - matchSource?: string; - } - | undefined; -}): boolean { - return ( - params.isDirectMessage === true && - params.roomConfigInfo?.config !== undefined && - params.roomConfigInfo.allowed === true && - params.roomConfigInfo.matchSource === "direct" - ); +function resolveMatrixInboundBodyText(params: { + rawBody: string; + filename?: string; + mediaPlaceholder?: string; + msgtype?: string; + hadMediaUrl: boolean; + mediaDownloadFailed: boolean; +}): string { + if (params.mediaPlaceholder) { + return params.rawBody || params.mediaPlaceholder; + } + if (!params.mediaDownloadFailed || !params.hadMediaUrl) { + return params.rawBody; + } + return formatMatrixMediaUnavailableText({ + body: params.rawBody, + filename: params.filename, + msgtype: params.msgtype, + }); } export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { @@ -131,10 +130,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam client, core, cfg, + accountId, runtime, logger, logVerboseMessage, allowFrom, + groupAllowFrom = [], roomsConfig, mentionRegexes, groupPolicy, @@ -146,36 +147,86 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam mediaMaxBytes, startupMs, startupGraceMs, + dropPreStartupMessages, directTracker, getRoomInfo, getMemberDisplayName, - accountId, + needsRoomAliasesForConfig, } = params; - const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID; - const pairing = createChannelPairingController({ - core, - channel: "matrix", - accountId: resolvedAccountId, + let cachedStoreAllowFrom: { + value: string[]; + expiresAtMs: number; + } | null = null; + const pairingReplySentAtMsBySender = new Map(); + const resolveThreadContext = createMatrixThreadContextResolver({ + client, + getMemberDisplayName, + logVerboseMessage, }); + const readStoreAllowFrom = async (): Promise => { + const now = Date.now(); + if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) { + return cachedStoreAllowFrom.value; + } + const value = await core.channel.pairing + .readAllowFromStore({ + channel: "matrix", + env: process.env, + accountId, + }) + .catch(() => []); + cachedStoreAllowFrom = { + value, + expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS, + }; + return value; + }; + + const shouldSendPairingReply = (senderId: string, created: boolean): boolean => { + const now = Date.now(); + if (created) { + pairingReplySentAtMsBySender.set(senderId, now); + return true; + } + const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId); + if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) { + return false; + } + pairingReplySentAtMsBySender.set(senderId, now); + if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) { + const oldestSender = pairingReplySentAtMsBySender.keys().next().value; + if (typeof oldestSender === "string") { + pairingReplySentAtMsBySender.delete(oldestSender); + } + } + return true; + }; + return async (roomId: string, event: MatrixRawEvent) => { try { const eventType = event.type; if (eventType === EventType.RoomMessageEncrypted) { - // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled + // Encrypted payloads are emitted separately after decryption. return; } - const isPollEvent = isPollStartType(eventType); - const locationContent = event.content as unknown as LocationMessageEventContent; + const isPollEvent = isPollEventType(eventType); + const isReactionEvent = eventType === EventType.Reaction; + const locationContent = event.content as LocationMessageEventContent; const isLocationEvent = eventType === EventType.Location || (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); - if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) { + if ( + eventType !== EventType.RoomMessage && + !isPollEvent && + !isLocationEvent && + !isReactionEvent + ) { return; } logVerboseMessage( - `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, + `matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, ); if (event.unsigned?.redacted_because) { return; @@ -190,39 +241,30 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } const eventTs = event.origin_server_ts; const eventAge = event.unsigned?.age; - if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { - return; - } - if ( - typeof eventTs !== "number" && - typeof eventAge === "number" && - eventAge > startupGraceMs - ) { - return; - } - - const roomInfo = await getRoomInfo(roomId); - const roomName = roomInfo.name; - const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean); - - let content = event.content as unknown as RoomMessageEventContent; - if (isPollEvent) { - const pollStartContent = event.content as unknown as PollStartContent; - const pollSummary = parsePollStartContent(pollStartContent); - if (pollSummary) { - pollSummary.eventId = event.event_id ?? ""; - pollSummary.roomId = roomId; - pollSummary.sender = senderId; - const senderDisplayName = await getMemberDisplayName(roomId, senderId); - pollSummary.senderName = senderDisplayName; - const pollText = formatPollAsText(pollSummary); - content = { - msgtype: "m.text", - body: pollText, - } as unknown as RoomMessageEventContent; - } else { + if (dropPreStartupMessages) { + if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { return; } + if ( + typeof eventTs !== "number" && + typeof eventAge === "number" && + eventAge > startupGraceMs + ) { + return; + } + } + + let content = event.content as RoomMessageEventContent; + + if ( + eventType === EventType.RoomMessage && + isMatrixVerificationRoomMessage({ + msgtype: (content as { msgtype?: unknown }).msgtype, + body: content.body, + }) + ) { + logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`); + return; } const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ @@ -237,122 +279,151 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } - let isDirectMessage = await directTracker.isDirectMessage({ + const isDirectMessage = await directTracker.isDirectMessage({ roomId, senderId, selfUserId, }); - - // Resolve room config early so explicitly configured rooms can override DM classification. - // This ensures rooms in the groups config are always treated as groups regardless of - // member count or protocol-level DM flags. Only explicit matches (not wildcards) trigger - // the override to avoid breaking DM routing when a wildcard entry exists. (See #9106) - const roomConfigInfo = resolveMatrixRoomConfig({ - rooms: roomsConfig, - roomId, - aliases: roomAliases, - name: roomName, - }); - if (shouldOverrideMatrixDmToGroup({ isDirectMessage, roomConfigInfo })) { - logVerboseMessage( - `matrix: overriding DM to group for configured room=${roomId} (${roomConfigInfo.matchKey})`, - ); - isDirectMessage = false; - } - const isRoom = !isDirectMessage; if (isRoom && groupPolicy === "disabled") { return; } - // Only expose room config for confirmed group rooms. DMs should never inherit - // group settings (skills, systemPrompt, autoReply) even when a wildcard entry exists. - const roomConfig = isRoom ? roomConfigInfo?.config : undefined; + + const roomInfoForConfig = + isRoom && needsRoomAliasesForConfig + ? await getRoomInfo(roomId, { includeAliases: true }) + : undefined; + const roomAliasesForConfig = roomInfoForConfig + ? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(Boolean) + : []; + const roomConfigInfo = isRoom + ? resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliasesForConfig, + }) + : undefined; + const roomConfig = roomConfigInfo?.config; const roomMatchMeta = roomConfigInfo ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ roomConfigInfo.matchSource ?? "none" }` : "matchKey=none matchSource=none"; - if (isRoom) { - const routeAccess = evaluateGroupRouteAccessForPolicy({ - groupPolicy, - routeAllowlistConfigured: Boolean(roomConfigInfo?.allowlistConfigured), - routeMatched: Boolean(roomConfig), - routeEnabled: roomConfigInfo?.allowed ?? true, - }); - if (!routeAccess.allowed) { - if (routeAccess.reason === "route_disabled") { - logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); - } else if (routeAccess.reason === "empty_allowlist") { - logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); - } else if (routeAccess.reason === "route_not_allowlisted") { - logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); - } + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + return; + } + if (isRoom && groupPolicy === "allowlist") { + if (!roomConfigInfo?.allowlistConfigured) { + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + return; + } + if (!roomConfig) { + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); return; } } - const senderName = await getMemberDisplayName(roomId, senderId); - const senderUsername = resolveMatrixSenderUsername(senderId); - const senderLabel = resolveMatrixInboundSenderLabel({ - senderName, + let senderNamePromise: Promise | null = null; + const getSenderName = async (): Promise => { + senderNamePromise ??= getMemberDisplayName(roomId, senderId).catch(() => senderId); + return await senderNamePromise; + }; + const storeAllowFrom = await readStoreAllowFrom(); + const roomUsers = roomConfig?.users ?? []; + const accessState = resolveMatrixMonitorAccessState({ + allowFrom, + storeAllowFrom, + groupAllowFrom, + roomUsers, senderId, - senderUsername, + isRoom, }); - const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } = - await resolveMatrixAccessState({ - isDirectMessage, - resolvedAccountId, - dmPolicy, - groupPolicy, - allowFrom, - groupAllowFrom, - senderId, - readStoreForDmPolicy: pairing.readStoreForDmPolicy, - }); + const { + effectiveAllowFrom, + effectiveGroupAllowFrom, + effectiveRoomUsers, + groupAllowConfigured, + directAllowMatch, + roomUserMatch, + groupAllowMatch, + commandAuthorizers, + } = accessState; if (isDirectMessage) { - const allowedDirectMessage = await enforceMatrixDirectMessageAccess({ - dmEnabled, - dmPolicy, - accessDecision: access.decision, - senderId, - senderName, - effectiveAllowFrom, - issuePairingChallenge: pairing.issueChallenge, - sendPairingReply: async (text) => { - await sendMessageMatrix(`room:${roomId}`, text, { client }); - }, - logVerboseMessage, - }); - if (!allowedDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { return; } + if (dmPolicy !== "open") { + const allowMatchMeta = formatAllowlistMatchMeta(directAllowMatch); + if (!directAllowMatch.allowed) { + if (!isReactionEvent && dmPolicy === "pairing") { + const senderName = await getSenderName(); + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + accountId, + meta: { name: senderName }, + }); + if (shouldSendPairingReply(senderId, created)) { + const pairingReply = core.channel.pairing.buildPairingReply({ + channel: "matrix", + idLine: `Your Matrix user id: ${senderId}`, + code, + }); + logVerboseMessage( + created + ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})` + : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + created + ? pairingReply + : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`, + { + client, + cfg, + accountId, + }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } + } else { + logVerboseMessage( + `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, + ); + } + } + if (isReactionEvent || dmPolicy !== "pairing") { + logVerboseMessage( + `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + } + return; + } + } } - const roomUsers = roomConfig?.users ?? []; - if (isRoom && roomUsers.length > 0) { - const userMatch = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(roomUsers), - userId: senderId, - }); - if (!userMatch.allowed) { - logVerboseMessage( - `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - userMatch, - )})`, - ); - return; - } + if (isRoom && roomUserMatch && !roomUserMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + roomUserMatch, + )})`, + ); + return; } - if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") { - const groupAllowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveGroupAllowFrom, - userId: senderId, - }); - if (!groupAllowMatch.allowed) { + if ( + isRoom && + groupPolicy === "allowlist" && + effectiveRoomUsers.length === 0 && + groupAllowConfigured + ) { + if (groupAllowMatch && !groupAllowMatch.allowed) { logVerboseMessage( `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( groupAllowMatch, @@ -365,13 +436,29 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); } - const rawBody = - locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); - let media: { - path: string; - contentType?: string; - placeholder: string; - } | null = null; + if (isReactionEvent) { + const senderName = await getSenderName(); + await handleInboundMatrixReaction({ + client, + core, + cfg, + accountId, + roomId, + event, + senderId, + senderLabel: senderName, + selfUserId, + isDirectMessage, + logVerboseMessage, + }); + return; + } + + const mentionPrecheckText = resolveMatrixMentionPrecheckText({ + eventType, + content, + locationText: locationPayload?.text, + }); const contentUrl = "url" in content && typeof content.url === "string" ? content.url : undefined; const contentFile = @@ -379,40 +466,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam ? content.file : undefined; const mediaUrl = contentUrl ?? contentFile?.url; - if (!rawBody && !mediaUrl) { - return; - } - - const contentInfo = - "info" in content && content.info && typeof content.info === "object" - ? (content.info as { mimetype?: string; size?: number }) - : undefined; - const contentType = contentInfo?.mimetype; - const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined; - if (mediaUrl?.startsWith("mxc://")) { - try { - media = await downloadMatrixMedia({ - client, - mxcUrl: mediaUrl, - contentType, - sizeBytes: contentSize, - maxBytes: mediaMaxBytes, - file: contentFile, - }); - } catch (err) { - logVerboseMessage(`matrix: media download failed: ${String(err)}`); - } - } - - const bodyText = rawBody || media?.placeholder || ""; - if (!bodyText) { + if (!mentionPrecheckText && !mediaUrl && !isPollEvent) { return; } const { wasMentioned, hasExplicitMention } = resolveMentions({ content, userId: selfUserId, - text: bodyText, + text: mentionPrecheckText, mentionRegexes, }); const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ @@ -420,31 +481,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam surface: "matrix", }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = resolveMatrixAllowListMatches({ - allowList: effectiveAllowFrom, - userId: senderId, - }); - const senderAllowedForGroup = groupAllowConfigured - ? resolveMatrixAllowListMatches({ - allowList: effectiveGroupAllowFrom, - userId: senderId, - }) - : false; - const senderAllowedForRoomUsers = - isRoom && roomUsers.length > 0 - ? resolveMatrixAllowListMatches({ - allowList: normalizeMatrixAllowList(roomUsers), - userId: senderId, - }) - : false; - const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); + const hasControlCommandInMessage = core.channel.text.hasControlCommand( + mentionPrecheckText, + cfg, + ); const commandGate = resolveControlCommandGate({ useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, - { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, - ], + authorizers: commandAuthorizers, allowTextCommands, hasControlCommand: hasControlCommandInMessage, }); @@ -481,6 +524,84 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } + if (isPollEvent) { + const pollSnapshot = await fetchMatrixPollSnapshot(client, roomId, event).catch((err) => { + logVerboseMessage( + `matrix: failed resolving poll snapshot room=${roomId} id=${event.event_id ?? "unknown"}: ${String(err)}`, + ); + return null; + }); + if (!pollSnapshot) { + return; + } + content = { + msgtype: "m.text", + body: pollSnapshot.text, + } as unknown as RoomMessageEventContent; + } + + let media: { + path: string; + contentType?: string; + placeholder: string; + } | null = null; + let mediaDownloadFailed = false; + const finalContentUrl = + "url" in content && typeof content.url === "string" ? content.url : undefined; + const finalContentFile = + "file" in content && content.file && typeof content.file === "object" + ? content.file + : undefined; + const finalMediaUrl = finalContentUrl ?? finalContentFile?.url; + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) + : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined; + if (finalMediaUrl?.startsWith("mxc://")) { + try { + media = await downloadMatrixMedia({ + client, + mxcUrl: finalMediaUrl, + contentType, + sizeBytes: contentSize, + maxBytes: mediaMaxBytes, + file: finalContentFile, + }); + } catch (err) { + mediaDownloadFailed = true; + const errorText = err instanceof Error ? err.message : String(err); + logVerboseMessage( + `matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`, + ); + logger.warn("matrix media download failed", { + roomId, + eventId: event.event_id, + msgtype: content.msgtype, + encrypted: Boolean(finalContentFile), + error: errorText, + }); + } + } + + const rawBody = + locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); + const bodyText = resolveMatrixInboundBodyText({ + rawBody, + filename: typeof content.filename === "string" ? content.filename : undefined, + mediaPlaceholder: media?.placeholder, + msgtype: content.msgtype, + hadMediaUrl: Boolean(finalMediaUrl), + mediaDownloadFailed, + }); + if (!bodyText) { + return; + } + const senderName = await getSenderName(); + const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined; + const roomName = roomInfo?.name; + const messageId = event.event_id ?? ""; const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; const threadRootId = resolveMatrixThreadRootId({ event, content }); @@ -488,118 +609,73 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam threadReplies, messageId, threadRootId, - isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available + isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata. }); + const threadContext = threadRootId + ? await resolveThreadContext({ roomId, threadRootId }) + : undefined; - const baseRoute = core.channel.routing.resolveAgentRoute({ + const { route, configuredBinding } = resolveMatrixInboundRoute({ cfg, - channel: "matrix", accountId, - peer: { - kind: isDirectMessage ? "direct" : "channel", - id: isDirectMessage ? senderId : roomId, - }, - // For DMs, pass roomId as parentPeer so the conversation is bindable by room ID - // while preserving DM trust semantics (secure 1:1, no group restrictions). - parentPeer: isDirectMessage ? { kind: "channel", id: roomId } : undefined, - }); - const baseRouteSession = resolveMatrixBaseRouteSession({ - buildAgentSessionKey: core.channel.routing.buildAgentSessionKey, - baseRoute, - isDirectMessage, roomId, - accountId, + senderId, + isDirectMessage, + messageId, + threadRootId, + eventTs: eventTs ?? undefined, + resolveAgentRoute: core.channel.routing.resolveAgentRoute, }); - - const route = { - ...baseRoute, - lastRoutePolicy: baseRouteSession.lastRoutePolicy, - sessionKey: threadRootId - ? `${baseRouteSession.sessionKey}:thread:${threadRootId}` - : baseRouteSession.sessionKey, - }; - - let threadStarterBody: string | undefined; - let threadLabel: string | undefined; - let parentSessionKey: string | undefined; - - if (threadRootId) { - const existingSession = core.channel.session.readSessionUpdatedAt({ - storePath: core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: baseRoute.agentId, - }), - sessionKey: route.sessionKey, + if (configuredBinding) { + const ensured = await ensureConfiguredAcpBindingReady({ + cfg, + configuredBinding, }); - - if (existingSession === undefined) { - try { - const rootEvent = await fetchEventSummary(client, roomId, threadRootId); - if (rootEvent?.body) { - const rootSenderName = rootEvent.sender - ? await getMemberDisplayName(roomId, rootEvent.sender) - : undefined; - - threadStarterBody = core.channel.reply.formatAgentEnvelope({ - channel: "Matrix", - from: rootSenderName ?? rootEvent.sender ?? "Unknown", - timestamp: rootEvent.timestamp, - envelope: core.channel.reply.resolveEnvelopeFormatOptions(cfg), - body: rootEvent.body, - }); - - threadLabel = `Matrix thread in ${roomName ?? roomId}`; - parentSessionKey = baseRoute.sessionKey; - } - } catch (err) { - logVerboseMessage( - `matrix: failed to fetch thread root ${threadRootId}: ${String(err)}`, - ); - } + if (!ensured.ok) { + logInboundDrop({ + log: logVerboseMessage, + channel: "matrix", + reason: "configured ACP binding unavailable", + target: configuredBinding.spec.conversationId, + }); + return; } } - const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = threadRootId - ? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]` - : `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const { storePath, envelopeOptions, previousTimestamp } = - resolveInboundSessionEnvelopeContext({ - cfg, - agentId: route.agentId, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatInboundEnvelope({ + const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ channel: "Matrix", from: envelopeFrom, timestamp: eventTs ?? undefined, previousTimestamp, envelope: envelopeOptions, body: textWithId, - chatType: isDirectMessage ? "direct" : "channel", - senderLabel, }); const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, - BodyForAgent: resolveMatrixBodyForAgent({ - isDirectMessage, - bodyText, - senderLabel, - }), RawBody: bodyText, CommandBody: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, To: `room:${roomId}`, SessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: threadRootId ? "thread" : isDirectMessage ? "direct" : "channel", + ChatType: isDirectMessage ? "direct" : "channel", ConversationLabel: envelopeFrom, SenderName: senderName, SenderId: senderId, - SenderUsername: senderUsername, + SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), GroupSubject: isRoom ? (roomName ?? roomId) : undefined, - GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, + GroupId: isRoom ? roomId : undefined, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, Provider: "matrix" as const, Surface: "matrix" as const, @@ -607,6 +683,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam MessageSid: messageId, ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), MessageThreadId: threadTarget, + ThreadStarterBody: threadContext?.threadStarterBody, Timestamp: eventTs ?? undefined, MediaPath: media?.path, MediaType: media?.contentType, @@ -616,9 +693,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam CommandSource: "text" as const, OriginatingChannel: "matrix" as const, OriginatingTo: `room:${roomId}`, - ThreadStarterBody: threadStarterBody, - ThreadLabel: threadLabel, - ParentSessionKey: parentSessionKey, }); await core.channel.session.recordInboundSession({ @@ -645,8 +719,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({ + cfg, + agentId: route.agentId, + accountId, + }); const shouldAckReaction = () => Boolean( ackReaction && @@ -673,48 +750,55 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } - let didSendReply = false; + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "matrix", accountId: route.accountId, }); - const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({ + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ cfg, agentId: route.agentId, channel: "matrix", accountId: route.accountId, - typing: { - start: () => sendTypingMatrix(roomId, true, undefined, client), - stop: () => sendTypingMatrix(roomId, false, undefined, client), - onStartError: (err) => { - logTypingFailure({ - log: logVerboseMessage, - channel: "matrix", - action: "start", - target: roomId, - error: err, - }); - }, - onStopError: (err) => { - logTypingFailure({ - log: logVerboseMessage, - channel: "matrix", - action: "stop", - target: roomId, - error: err, - }); - }, + }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingMatrix(roomId, true, undefined, client), + stop: () => sendTypingMatrix(roomId, false, undefined, client), + onStartError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "start", + target: roomId, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "stop", + target: roomId, + error: err, + }); }, }); - const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - ...replyPipeline, - humanDelay, - typingCallbacks, - deliver: async (payload) => { + ...prefixOptions, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { await deliverMatrixReplies({ + cfg, replies: [payload], roomId, client, @@ -723,43 +807,35 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam replyToMode, threadId: threadTarget, accountId: route.accountId, + mediaLocalRoots, tableMode, }); - didSendReply = true; }, - onError: (err, info) => { + onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); }, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, }); - const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({ + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, cfg, - ctxPayload, dispatcher, - onSettled: () => { - markDispatchIdle(); - }, replyOptions: { ...replyOptions, skillFilter: roomConfig?.skills, onModelSelected, }, }); + markDispatchIdle(); if (!queuedFinal) { return; } - didSendReply = true; const finalCount = counts.final; logVerboseMessage( `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, ); - if (didSendReply) { - const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160); - core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, { - sessionKey: route.sessionKey, - contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, - }); - } } catch (err) { runtime.error?.(`matrix handler failed: ${String(err)}`); } diff --git a/extensions/matrix/src/matrix/monitor/inbound-body.test.ts b/extensions/matrix/src/matrix/monitor/inbound-body.test.ts deleted file mode 100644 index 8b5c63c89a9..00000000000 --- a/extensions/matrix/src/matrix/monitor/inbound-body.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveMatrixBodyForAgent, - resolveMatrixInboundSenderLabel, - resolveMatrixSenderUsername, -} from "./inbound-body.js"; - -describe("resolveMatrixSenderUsername", () => { - it("extracts localpart without leading @", () => { - expect(resolveMatrixSenderUsername("@bu:matrix.example.org")).toBe("bu"); - }); -}); - -describe("resolveMatrixInboundSenderLabel", () => { - it("uses provided senderUsername when present", () => { - expect( - resolveMatrixInboundSenderLabel({ - senderName: "Bu", - senderId: "@bu:matrix.example.org", - senderUsername: "BU_CUSTOM", - }), - ).toBe("Bu (BU_CUSTOM)"); - }); - - it("includes sender username when it differs from display name", () => { - expect( - resolveMatrixInboundSenderLabel({ - senderName: "Bu", - senderId: "@bu:matrix.example.org", - }), - ).toBe("Bu (bu)"); - }); - - it("falls back to sender username when display name is blank", () => { - expect( - resolveMatrixInboundSenderLabel({ - senderName: " ", - senderId: "@zhang:matrix.example.org", - }), - ).toBe("zhang"); - }); - - it("falls back to sender id when username cannot be parsed", () => { - expect( - resolveMatrixInboundSenderLabel({ - senderName: "", - senderId: "matrix-user-without-colon", - }), - ).toBe("matrix-user-without-colon"); - }); -}); - -describe("resolveMatrixBodyForAgent", () => { - it("keeps direct message body unchanged", () => { - expect( - resolveMatrixBodyForAgent({ - isDirectMessage: true, - bodyText: "show me my commits", - senderLabel: "Bu (bu)", - }), - ).toBe("show me my commits"); - }); - - it("prefixes non-direct message body with sender label", () => { - expect( - resolveMatrixBodyForAgent({ - isDirectMessage: false, - bodyText: "show me my commits", - senderLabel: "Bu (bu)", - }), - ).toBe("Bu (bu): show me my commits"); - }); -}); diff --git a/extensions/matrix/src/matrix/monitor/inbound-body.ts b/extensions/matrix/src/matrix/monitor/inbound-body.ts deleted file mode 100644 index 48ad8d31e79..00000000000 --- a/extensions/matrix/src/matrix/monitor/inbound-body.ts +++ /dev/null @@ -1,28 +0,0 @@ -export function resolveMatrixSenderUsername(senderId: string): string | undefined { - const username = senderId.split(":")[0]?.replace(/^@/, "").trim(); - return username ? username : undefined; -} - -export function resolveMatrixInboundSenderLabel(params: { - senderName: string; - senderId: string; - senderUsername?: string; -}): string { - const senderName = params.senderName.trim(); - const senderUsername = params.senderUsername ?? resolveMatrixSenderUsername(params.senderId); - if (senderName && senderUsername && senderName !== senderUsername) { - return `${senderName} (${senderUsername})`; - } - return senderName || senderUsername || params.senderId; -} - -export function resolveMatrixBodyForAgent(params: { - isDirectMessage: boolean; - bodyText: string; - senderLabel: string; -}): string { - if (params.isDirectMessage) { - return params.bodyText; - } - return `${params.senderLabel}: ${params.bodyText}`; -} diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 89ae5188e9c..30d7a6d4890 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -1,18 +1,274 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_STARTUP_GRACE_MS, isConfiguredMatrixRoomEntry } from "./index.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -describe("monitorMatrixProvider helpers", () => { - it("treats !-prefixed room IDs as configured room entries", () => { - expect(isConfiguredMatrixRoomEntry("!abc123")).toBe(true); - expect(isConfiguredMatrixRoomEntry("!RoomMixedCase")).toBe(true); +const hoisted = vi.hoisted(() => { + const callOrder: string[] = []; + const client = { + id: "matrix-client", + hasPersistedSyncState: vi.fn(() => false), + }; + const createMatrixRoomMessageHandler = vi.fn(() => vi.fn()); + let startClientError: Error | null = null; + const resolveTextChunkLimit = vi.fn< + (cfg: unknown, channel: unknown, accountId?: unknown) => number + >(() => 4000); + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + const stopThreadBindingManager = vi.fn(); + const stopSharedClientInstance = vi.fn(); + const setActiveMatrixClient = vi.fn(); + return { + callOrder, + client, + createMatrixRoomMessageHandler, + logger, + resolveTextChunkLimit, + setActiveMatrixClient, + startClientError, + stopSharedClientInstance, + stopThreadBindingManager, + }; +}); + +vi.mock("openclaw/plugin-sdk/matrix", () => ({ + GROUP_POLICY_BLOCKED_LABEL: { + room: "room", + }, + mergeAllowlist: ({ existing, additions }: { existing: string[]; additions: string[] }) => [ + ...existing, + ...additions, + ], + resolveThreadBindingIdleTimeoutMsForChannel: () => 24 * 60 * 60 * 1000, + resolveThreadBindingMaxAgeMsForChannel: () => 0, + resolveAllowlistProviderRuntimeGroupPolicy: () => ({ + groupPolicy: "allowlist", + providerMissingFallbackApplied: false, + }), + resolveDefaultGroupPolicy: () => "allowlist", + summarizeMapping: vi.fn(), + warnMissingProviderGroupPolicyFallbackOnce: vi.fn(), +})); + +vi.mock("../../resolve-targets.js", () => ({ + resolveMatrixTargets: vi.fn(async () => []), +})); + +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => ({ + config: { + loadConfig: () => ({ + channels: { + matrix: {}, + }, + }), + writeConfigFile: vi.fn(), + }, + logging: { + getChildLogger: () => hoisted.logger, + shouldLogVerbose: () => false, + }, + channel: { + mentions: { + buildMentionRegexes: () => [], + }, + text: { + resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) => + hoisted.resolveTextChunkLimit(cfg, channel, accountId), + }, + }, + system: { + formatNativeDependencyHint: () => "", + }, + media: { + loadWebMedia: vi.fn(), + }, + }), +})); + +vi.mock("../accounts.js", () => ({ + resolveMatrixAccount: () => ({ + accountId: "default", + config: { + dm: {}, + }, + }), +})); + +vi.mock("../active-client.js", () => ({ + setActiveMatrixClient: hoisted.setActiveMatrixClient, +})); + +vi.mock("../client.js", () => ({ + isBunRuntime: () => false, + resolveMatrixAuth: vi.fn(async () => ({ + accountId: "default", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + initialSyncLimit: 20, + encryption: false, + })), + resolveMatrixAuthContext: vi.fn(() => ({ + accountId: "default", + })), + resolveSharedMatrixClient: vi.fn(async (params: { startClient?: boolean }) => { + if (params.startClient === false) { + hoisted.callOrder.push("prepare-client"); + return hoisted.client; + } + if (!hoisted.callOrder.includes("create-manager")) { + throw new Error("Matrix client started before thread bindings were registered"); + } + if (hoisted.startClientError) { + throw hoisted.startClientError; + } + hoisted.callOrder.push("start-client"); + return hoisted.client; + }), + stopSharedClientInstance: hoisted.stopSharedClientInstance, +})); + +vi.mock("../config-update.js", () => ({ + updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg), +})); + +vi.mock("../device-health.js", () => ({ + summarizeMatrixDeviceHealth: vi.fn(() => ({ + staleOpenClawDevices: [], + })), +})); + +vi.mock("../profile.js", () => ({ + syncMatrixOwnProfile: vi.fn(async () => ({ + displayNameUpdated: false, + avatarUpdated: false, + convertedAvatarFromHttp: false, + resolvedAvatarUrl: undefined, + })), +})); + +vi.mock("../thread-bindings.js", () => ({ + createMatrixThreadBindingManager: vi.fn(async () => { + hoisted.callOrder.push("create-manager"); + return { + accountId: "default", + stop: hoisted.stopThreadBindingManager, + }; + }), +})); + +vi.mock("./allowlist.js", () => ({ + normalizeMatrixUserId: (value: string) => value, +})); + +vi.mock("./auto-join.js", () => ({ + registerMatrixAutoJoin: vi.fn(), +})); + +vi.mock("./direct.js", () => ({ + createDirectRoomTracker: vi.fn(() => ({ + isDirectMessage: vi.fn(async () => false), + })), +})); + +vi.mock("./events.js", () => ({ + registerMatrixMonitorEvents: vi.fn(() => { + hoisted.callOrder.push("register-events"); + }), +})); + +vi.mock("./handler.js", () => ({ + createMatrixRoomMessageHandler: hoisted.createMatrixRoomMessageHandler, +})); + +vi.mock("./legacy-crypto-restore.js", () => ({ + maybeRestoreLegacyMatrixBackup: vi.fn(), +})); + +vi.mock("./room-info.js", () => ({ + createMatrixRoomInfoResolver: vi.fn(() => ({ + getRoomInfo: vi.fn(async () => ({ + altAliases: [], + })), + getMemberDisplayName: vi.fn(async () => "Bot"), + })), +})); + +vi.mock("./startup-verification.js", () => ({ + ensureMatrixStartupVerification: vi.fn(), +})); + +describe("monitorMatrixProvider", () => { + beforeEach(() => { + vi.resetModules(); + hoisted.callOrder.length = 0; + hoisted.startClientError = null; + hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000); + hoisted.setActiveMatrixClient.mockReset(); + hoisted.stopSharedClientInstance.mockReset(); + hoisted.stopThreadBindingManager.mockReset(); + hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false); + hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn()); + Object.values(hoisted.logger).forEach((mock) => mock.mockReset()); }); - it("requires a homeserver suffix for # aliases", () => { - expect(isConfiguredMatrixRoomEntry("#alias:example.org")).toBe(true); - expect(isConfiguredMatrixRoomEntry("#alias")).toBe(false); + it("registers Matrix thread bindings before starting the client", async () => { + const { monitorMatrixProvider } = await import("./index.js"); + const abortController = new AbortController(); + abortController.abort(); + + await monitorMatrixProvider({ abortSignal: abortController.signal }); + + expect(hoisted.callOrder).toEqual([ + "prepare-client", + "create-manager", + "register-events", + "start-client", + ]); + expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1); }); - it("uses a non-zero startup grace window", () => { - expect(DEFAULT_STARTUP_GRACE_MS).toBe(5000); + it("resolves text chunk limit for the effective Matrix account", async () => { + const { monitorMatrixProvider } = await import("./index.js"); + const abortController = new AbortController(); + abortController.abort(); + + await monitorMatrixProvider({ abortSignal: abortController.signal }); + + expect(hoisted.resolveTextChunkLimit).toHaveBeenCalledWith( + expect.anything(), + "matrix", + "default", + ); + }); + + it("cleans up thread bindings and shared clients when startup fails", async () => { + const { monitorMatrixProvider } = await import("./index.js"); + hoisted.startClientError = new Error("start failed"); + + await expect(monitorMatrixProvider()).rejects.toThrow("start failed"); + + expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1); + expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1); + expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default"); + expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default"); + }); + + it("disables cold-start backlog dropping when sync state already exists", async () => { + hoisted.client.hasPersistedSyncState.mockReturnValue(true); + const { monitorMatrixProvider } = await import("./index.js"); + const abortController = new AbortController(); + abortController.abort(); + + await monitorMatrixProvider({ abortSignal: abortController.signal }); + + expect(hoisted.createMatrixRoomMessageHandler).toHaveBeenCalledWith( + expect.objectContaining({ + dropPreStartupMessages: false, + }), + ); }); }); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 12091aaeded..8eff9f740f6 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,30 +1,32 @@ +import { format } from "node:util"; import { GROUP_POLICY_BLOCKED_LABEL, - mergeAllowlist, - resolveRuntimeEnv, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, type RuntimeEnv, -} from "../../../runtime-api.js"; -import { resolveMatrixTargets } from "../../resolve-targets.js"; +} from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig, MatrixConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; import { resolveMatrixAccount } from "../accounts.js"; import { setActiveMatrixClient } from "../active-client.js"; import { isBunRuntime, resolveMatrixAuth, + resolveMatrixAuthContext, resolveSharedMatrixClient, - stopSharedClientForAccount, + stopSharedClientInstance, } from "../client.js"; -import { normalizeMatrixUserId } from "./allowlist.js"; +import { createMatrixThreadBindingManager } from "../thread-bindings.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; +import { resolveMatrixMonitorConfig } from "./config.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; +import { runMatrixStartupMaintenance } from "./startup.js"; export type MonitorMatrixOpts = { runtime?: RuntimeEnv; @@ -36,199 +38,6 @@ export type MonitorMatrixOpts = { }; const DEFAULT_MEDIA_MAX_MB = 20; -export const DEFAULT_STARTUP_GRACE_MS = 5000; - -export function isConfiguredMatrixRoomEntry(entry: string): boolean { - return entry.startsWith("!") || (entry.startsWith("#") && entry.includes(":")); -} - -function normalizeMatrixUserEntry(raw: string): string { - return raw - .replace(/^matrix:/i, "") - .replace(/^user:/i, "") - .trim(); -} - -function normalizeMatrixRoomEntry(raw: string): string { - return raw - .replace(/^matrix:/i, "") - .replace(/^(room|channel):/i, "") - .trim(); -} - -function isMatrixUserId(value: string): boolean { - return value.startsWith("@") && value.includes(":"); -} - -async function resolveMatrixUserAllowlist(params: { - cfg: CoreConfig; - runtime: RuntimeEnv; - label: string; - list?: Array; -}): Promise { - let allowList = params.list ?? []; - if (allowList.length === 0) { - return allowList.map(String); - } - const entries = allowList - .map((entry) => normalizeMatrixUserEntry(String(entry))) - .filter((entry) => entry && entry !== "*"); - if (entries.length === 0) { - return allowList.map(String); - } - const mapping: string[] = []; - const unresolved: string[] = []; - const additions: string[] = []; - const pending: string[] = []; - for (const entry of entries) { - if (isMatrixUserId(entry)) { - additions.push(normalizeMatrixUserId(entry)); - continue; - } - pending.push(entry); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg: params.cfg, - inputs: pending, - kind: "user", - runtime: params.runtime, - }); - for (const entry of resolved) { - if (entry.resolved && entry.id) { - const normalizedId = normalizeMatrixUserId(entry.id); - additions.push(normalizedId); - mapping.push(`${entry.input}→${normalizedId}`); - } else { - unresolved.push(entry.input); - } - } - } - allowList = mergeAllowlist({ existing: allowList, additions }); - summarizeMapping(params.label, mapping, unresolved, params.runtime); - if (unresolved.length > 0) { - params.runtime.log?.( - `${params.label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, - ); - } - return allowList.map(String); -} - -async function resolveMatrixRoomsConfig(params: { - cfg: CoreConfig; - runtime: RuntimeEnv; - roomsConfig?: Record; -}): Promise | undefined> { - let roomsConfig = params.roomsConfig; - if (!roomsConfig || Object.keys(roomsConfig).length === 0) { - return roomsConfig; - } - const mapping: string[] = []; - const unresolved: string[] = []; - const nextRooms: Record = {}; - 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 trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = normalizeMatrixRoomEntry(trimmed); - if (isConfiguredMatrixRoomEntry(cleaned)) { - if (!nextRooms[cleaned]) { - nextRooms[cleaned] = roomConfig; - } - if (cleaned !== entry) { - mapping.push(`${entry}→${cleaned}`); - } - continue; - } - pending.push({ input: entry, query: trimmed, config: roomConfig }); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg: params.cfg, - 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) { - if (!nextRooms[entry.id]) { - nextRooms[entry.id] = source.config; - } - mapping.push(`${source.input}→${entry.id}`); - } else { - unresolved.push(source.input); - } - }); - } - roomsConfig = nextRooms; - summarizeMapping("matrix rooms", mapping, unresolved, 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.", - ); - } - if (Object.keys(roomsConfig).length === 0) { - return roomsConfig; - } - const nextRoomsWithUsers = { ...roomsConfig }; - for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) { - const users = roomConfig?.users ?? []; - if (users.length === 0) { - continue; - } - const resolvedUsers = await resolveMatrixUserAllowlist({ - cfg: params.cfg, - runtime: params.runtime, - label: `matrix room users (${roomKey})`, - list: users, - }); - if (resolvedUsers !== users) { - nextRoomsWithUsers[roomKey] = { ...roomConfig, users: resolvedUsers }; - } - } - return nextRoomsWithUsers; -} - -async function resolveMatrixMonitorConfig(params: { - cfg: CoreConfig; - runtime: RuntimeEnv; - accountConfig: MatrixConfig; -}): Promise<{ - allowFrom: string[]; - groupAllowFrom: string[]; - roomsConfig?: Record; -}> { - const allowFrom = await resolveMatrixUserAllowlist({ - cfg: params.cfg, - runtime: params.runtime, - label: "matrix dm allowlist", - list: params.accountConfig.dm?.allowFrom ?? [], - }); - const groupAllowFrom = await resolveMatrixUserAllowlist({ - cfg: params.cfg, - runtime: params.runtime, - label: "matrix group allowlist", - list: params.accountConfig.groupAllowFrom ?? [], - }); - const roomsConfig = await resolveMatrixRoomsConfig({ - cfg: params.cfg, - runtime: params.runtime, - roomsConfig: params.accountConfig.groups ?? params.accountConfig.rooms, - }); - return { allowFrom, groupAllowFrom, roomsConfig }; -} export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise { if (isBunRuntime()) { @@ -236,15 +45,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } const core = getMatrixRuntime(); let cfg = core.config.loadConfig() as CoreConfig; - if (cfg.channels?.matrix?.enabled === false) { + if (cfg.channels?.["matrix"]?.enabled === false) { return; } const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const runtime: RuntimeEnv = resolveRuntimeEnv({ - runtime: opts.runtime, - logger, - }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => { + logger.info(formatRuntimeMessage(...args)); + }, + error: (...args) => { + logger.error(formatRuntimeMessage(...args)); + }, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; const logVerboseMessage = (message: string) => { if (!core.logging.shouldLogVerbose()) { return; @@ -252,24 +69,42 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi logger.debug?.(message); }; - // Resolve account-specific config for multi-account support - const account = resolveMatrixAccount({ cfg, accountId: opts.accountId }); - const accountConfig = account.config; - const allowlistOnly = accountConfig.allowlistOnly === true; - const { allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ + const authContext = resolveMatrixAuthContext({ cfg, - runtime, - accountConfig, + accountId: opts.accountId, }); + const effectiveAccountId = authContext.accountId; + + // Resolve account-specific config for multi-account support + const account = resolveMatrixAccount({ cfg, accountId: effectiveAccountId }); + const accountConfig = account.config; + + const allowlistOnly = accountConfig.allowlistOnly === true; + let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String); + let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); + let roomsConfig = accountConfig.groups ?? accountConfig.rooms; + let needsRoomAliasesForConfig = false; + + ({ allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ + cfg, + accountId: effectiveAccountId, + allowFrom, + groupAllowFrom, + roomsConfig, + runtime, + })); + needsRoomAliasesForConfig = Boolean( + roomsConfig && Object.keys(roomsConfig).some((key) => key.trim().startsWith("#")), + ); cfg = { ...cfg, channels: { ...cfg.channels, matrix: { - ...cfg.channels?.matrix, + ...cfg.channels?.["matrix"], dm: { - ...cfg.channels?.matrix?.dm, + ...cfg.channels?.["matrix"]?.dm, allowFrom, }, groupAllowFrom, @@ -278,7 +113,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }, }; - const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId }); + const auth = await resolveMatrixAuth({ cfg, accountId: effectiveAccountId }); const resolvedInitialSyncLimit = typeof opts.initialSyncLimit === "number" ? Math.max(0, Math.floor(opts.initialSyncLimit)) @@ -291,15 +126,29 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi cfg, auth: authWithLimit, startClient: false, - accountId: opts.accountId, + accountId: auth.accountId, }); - setActiveMatrixClient(client, opts.accountId); + setActiveMatrixClient(client, auth.accountId); + let cleanedUp = false; + let threadBindingManager: { accountId: string; stop: () => void } | null = null; + const cleanup = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + try { + threadBindingManager?.stop(); + } finally { + stopSharedClientInstance(client); + setActiveMatrixClient(null, auth.accountId); + } + }; const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.matrix !== undefined, + providerConfigPresent: cfg.channels?.["matrix"] !== undefined, groupPolicy: accountConfig.groupPolicy, defaultGroupPolicy, }); @@ -313,20 +162,30 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; const threadReplies = accountConfig.threadReplies ?? "inbound"; + const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({ + cfg, + channel: "matrix", + accountId: account.accountId, + }); + const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMsForChannel({ + cfg, + channel: "matrix", + accountId: account.accountId, + }); const dmConfig = accountConfig.dm; const dmEnabled = dmConfig?.enabled ?? true; const dmPolicyRaw = dmConfig?.policy ?? "pairing"; const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; - const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix", account.accountId); const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const startupMs = Date.now(); - const startupGraceMs = DEFAULT_STARTUP_GRACE_MS; - const directTracker = createDirectRoomTracker(client, { - log: logVerboseMessage, - includeMemberCountInLogs: core.logging.shouldLogVerbose(), - }); - registerMatrixAutoJoin({ client, cfg, runtime }); + const startupGraceMs = 0; + // Cold starts should ignore old room history, but once we have a persisted + // /sync cursor we want restart backlogs to replay just like other channels. + const dropPreStartupMessages = !client.hasPersistedSyncState(); + const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); + registerMatrixAutoJoin({ client, accountConfig, runtime }); const warnedEncryptedRooms = new Set(); const warnedCryptoMissingRooms = new Set(); @@ -335,10 +194,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi client, core, cfg, + accountId: account.accountId, runtime, logger, logVerboseMessage, allowFrom, + groupAllowFrom, roomsConfig, mentionRegexes, groupPolicy, @@ -350,65 +211,81 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi mediaMaxBytes, startupMs, startupGraceMs, + dropPreStartupMessages, directTracker, getRoomInfo, getMemberDisplayName, - accountId: opts.accountId, + needsRoomAliasesForConfig, }); - registerMatrixMonitorEvents({ - client, - auth, - logVerboseMessage, - warnedEncryptedRooms, - warnedCryptoMissingRooms, - logger, - formatNativeDependencyHint: core.system.formatNativeDependencyHint, - onRoomMessage: handleRoomMessage, - }); + try { + threadBindingManager = await createMatrixThreadBindingManager({ + accountId: account.accountId, + auth, + client, + env: process.env, + idleTimeoutMs: threadBindingIdleTimeoutMs, + maxAgeMs: threadBindingMaxAgeMs, + logVerboseMessage, + }); + logVerboseMessage( + `matrix: thread bindings ready account=${threadBindingManager.accountId} idleMs=${threadBindingIdleTimeoutMs} maxAgeMs=${threadBindingMaxAgeMs}`, + ); - logVerboseMessage("matrix: starting client"); - await resolveSharedMatrixClient({ - cfg, - auth: authWithLimit, - accountId: opts.accountId, - }); - logVerboseMessage("matrix: client started"); + registerMatrixMonitorEvents({ + cfg, + client, + auth, + directTracker, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint: core.system.formatNativeDependencyHint, + onRoomMessage: handleRoomMessage, + }); - // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient - logger.info(`matrix: logged in as ${auth.userId}`); + // Register Matrix thread bindings before the client starts syncing so threaded + // commands during startup never observe Matrix as "unavailable". + logVerboseMessage("matrix: starting client"); + await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + accountId: auth.accountId, + }); + logVerboseMessage("matrix: client started"); - // If E2EE is enabled, trigger device verification - if (auth.encryption && client.crypto) { - try { - // Request verification from other sessions - const verificationRequest = await ( - client.crypto as { requestOwnUserVerification?: () => Promise } - ).requestOwnUserVerification?.(); - if (verificationRequest) { - logger.info("matrix: device verification requested - please verify in another client"); - } - } catch (err) { - logger.debug?.("Device verification request failed (may already be verified)", { - error: String(err), - }); - } - } + // Shared client is already started via resolveSharedMatrixClient. + logger.info(`matrix: logged in as ${auth.userId}`); - await new Promise((resolve) => { - const onAbort = () => { - try { + await runMatrixStartupMaintenance({ + client, + auth, + accountId: account.accountId, + effectiveAccountId, + accountConfig, + logger, + logVerboseMessage, + loadConfig: () => core.config.loadConfig() as CoreConfig, + writeConfigFile: async (nextCfg) => await core.config.writeConfigFile(nextCfg), + loadWebMedia: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), + env: process.env, + }); + + await new Promise((resolve) => { + const onAbort = () => { logVerboseMessage("matrix: stopping client"); - stopSharedClientForAccount(auth, opts.accountId); - } finally { - setActiveMatrixClient(null, opts.accountId); + cleanup(); resolve(); + }; + if (opts.abortSignal?.aborted) { + onAbort(); + return; } - }; - if (opts.abortSignal?.aborted) { - onAbort(); - return; - } - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); + } catch (err) { + cleanup(); + throw err; + } } diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts new file mode 100644 index 00000000000..887dd25624a --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts @@ -0,0 +1,216 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix"; +import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../../../../test/helpers/temp-home.js"; +import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; + +function createBackupStatus() { + return { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: true, + keyLoadError: null, + }; +} + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf8"); +} + +describe("maybeRestoreLegacyMatrixBackup", () => { + it("marks pending legacy backup restore as completed after success", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const auth = { + accountId: "default", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + ...auth, + }); + writeFile( + path.join(rootDir, "legacy-crypto-migration.json"), + JSON.stringify({ + version: 1, + accountId: "default", + roomKeyCounts: { total: 10, backedUp: 8 }, + restoreStatus: "pending", + }), + ); + + const restoreRoomKeyBackup = vi.fn(async () => ({ + success: true, + restoredAt: "2026-03-08T10:00:00.000Z", + imported: 8, + total: 8, + loadedFromSecretStorage: true, + backupVersion: "1", + backup: createBackupStatus(), + })); + + const result = await maybeRestoreLegacyMatrixBackup({ + client: { restoreRoomKeyBackup }, + auth, + stateDir, + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + HOME: home, + }, + }); + + expect(result).toEqual({ + kind: "restored", + imported: 8, + total: 8, + localOnlyKeys: 2, + }); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + restoreStatus: string; + importedCount: number; + totalCount: number; + }; + expect(state.restoreStatus).toBe("completed"); + expect(state.importedCount).toBe(8); + expect(state.totalCount).toBe(8); + }); + }); + + it("keeps the restore pending when startup restore fails", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const auth = { + accountId: "default", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + ...auth, + }); + writeFile( + path.join(rootDir, "legacy-crypto-migration.json"), + JSON.stringify({ + version: 1, + accountId: "default", + roomKeyCounts: { total: 5, backedUp: 5 }, + restoreStatus: "pending", + }), + ); + + const result = await maybeRestoreLegacyMatrixBackup({ + client: { + restoreRoomKeyBackup: async () => ({ + success: false, + error: "backup unavailable", + imported: 0, + total: 0, + loadedFromSecretStorage: false, + backupVersion: null, + backup: createBackupStatus(), + }), + }, + auth, + stateDir, + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + HOME: home, + }, + }); + + expect(result).toEqual({ + kind: "failed", + error: "backup unavailable", + localOnlyKeys: 0, + }); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + restoreStatus: string; + lastError: string; + }; + expect(state.restoreStatus).toBe("pending"); + expect(state.lastError).toBe("backup unavailable"); + }); + }); + + it("restores from a sibling token-hash directory when the access token changed", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const oldAuth = { + accountId: "default", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-old", + }; + const newAuth = { + ...oldAuth, + accessToken: "tok-new", + }; + const { rootDir: oldRootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + ...oldAuth, + }); + const { rootDir: newRootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + ...newAuth, + }); + writeFile( + path.join(oldRootDir, "legacy-crypto-migration.json"), + JSON.stringify({ + version: 1, + accountId: "default", + roomKeyCounts: { total: 3, backedUp: 3 }, + restoreStatus: "pending", + }), + ); + + const restoreRoomKeyBackup = vi.fn(async () => ({ + success: true, + restoredAt: "2026-03-08T10:00:00.000Z", + imported: 3, + total: 3, + loadedFromSecretStorage: true, + backupVersion: "1", + backup: createBackupStatus(), + })); + + const result = await maybeRestoreLegacyMatrixBackup({ + client: { restoreRoomKeyBackup }, + auth: newAuth, + stateDir, + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + HOME: home, + }, + }); + + expect(result).toEqual({ + kind: "restored", + imported: 3, + total: 3, + localOnlyKeys: 0, + }); + const oldState = JSON.parse( + fs.readFileSync(path.join(oldRootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + restoreStatus: string; + }; + expect(oldState.restoreStatus).toBe("completed"); + expect(fs.existsSync(path.join(newRootDir, "legacy-crypto-migration.json"))).toBe(false); + }); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts new file mode 100644 index 00000000000..f4d17f400a1 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts @@ -0,0 +1,139 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix"; +import { getMatrixRuntime } from "../../runtime.js"; +import { resolveMatrixStoragePaths } from "../client/storage.js"; +import type { MatrixAuth } from "../client/types.js"; +import type { MatrixClient } from "../sdk.js"; + +type MatrixLegacyCryptoMigrationState = { + version: 1; + accountId: string; + roomKeyCounts: { + total: number; + backedUp: number; + } | null; + restoreStatus: "pending" | "completed" | "manual-action-required"; + restoredAt?: string; + importedCount?: number; + totalCount?: number; + lastError?: string | null; +}; + +export type MatrixLegacyCryptoRestoreResult = + | { kind: "skipped" } + | { + kind: "restored"; + imported: number; + total: number; + localOnlyKeys: number; + } + | { + kind: "failed"; + error: string; + localOnlyKeys: number; + }; + +function isMigrationState(value: unknown): value is MatrixLegacyCryptoMigrationState { + return ( + Boolean(value) && typeof value === "object" && (value as { version?: unknown }).version === 1 + ); +} + +async function resolvePendingMigrationStatePath(params: { + stateDir: string; + auth: Pick; +}): Promise<{ + statePath: string; + value: MatrixLegacyCryptoMigrationState | null; +}> { + const { rootDir } = resolveMatrixStoragePaths({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.auth.accountId, + deviceId: params.auth.deviceId, + stateDir: params.stateDir, + }); + const directStatePath = path.join(rootDir, "legacy-crypto-migration.json"); + const { value: directValue } = + await readJsonFileWithFallback(directStatePath, null); + if (isMigrationState(directValue) && directValue.restoreStatus === "pending") { + return { statePath: directStatePath, value: directValue }; + } + + const accountStorageDir = path.dirname(rootDir); + let siblingEntries: string[] = []; + try { + siblingEntries = (await fs.readdir(accountStorageDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((entry) => path.join(accountStorageDir, entry) !== rootDir) + .toSorted((left, right) => left.localeCompare(right)); + } catch { + return { statePath: directStatePath, value: directValue }; + } + + for (const sibling of siblingEntries) { + const siblingStatePath = path.join(accountStorageDir, sibling, "legacy-crypto-migration.json"); + const { value } = await readJsonFileWithFallback( + siblingStatePath, + null, + ); + if (isMigrationState(value) && value.restoreStatus === "pending") { + return { statePath: siblingStatePath, value }; + } + } + return { statePath: directStatePath, value: directValue }; +} + +export async function maybeRestoreLegacyMatrixBackup(params: { + client: Pick; + auth: Pick; + env?: NodeJS.ProcessEnv; + stateDir?: string; +}): Promise { + const env = params.env ?? process.env; + const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const { statePath, value } = await resolvePendingMigrationStatePath({ + stateDir, + auth: params.auth, + }); + if (!isMigrationState(value) || value.restoreStatus !== "pending") { + return { kind: "skipped" }; + } + + const restore = await params.client.restoreRoomKeyBackup(); + const localOnlyKeys = + value.roomKeyCounts && value.roomKeyCounts.total > value.roomKeyCounts.backedUp + ? value.roomKeyCounts.total - value.roomKeyCounts.backedUp + : 0; + + if (restore.success) { + await writeJsonFileAtomically(statePath, { + ...value, + restoreStatus: "completed", + restoredAt: restore.restoredAt ?? new Date().toISOString(), + importedCount: restore.imported, + totalCount: restore.total, + lastError: null, + } satisfies MatrixLegacyCryptoMigrationState); + return { + kind: "restored", + imported: restore.imported, + total: restore.total, + localOnlyKeys, + }; + } + + await writeJsonFileAtomically(statePath, { + ...value, + lastError: restore.error ?? "unknown", + } satisfies MatrixLegacyCryptoMigrationState); + return { + kind: "failed", + error: restore.error ?? "unknown", + localOnlyKeys, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts index 8d4351a6f5a..bb22f0536a8 100644 --- a/extensions/matrix/src/matrix/monitor/location.ts +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -1,9 +1,9 @@ -import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk"; import { formatLocationText, toLocationContext, type NormalizedLocation, -} from "../../../runtime-api.js"; +} from "openclaw/plugin-sdk/matrix"; +import type { LocationMessageEventContent } from "../sdk.js"; import { EventType } from "./types.js"; export type MatrixLocationPayload = { diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index a142893ef44..19ee48cb57e 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -1,5 +1,5 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime } from "../../../runtime-api.js"; import { setMatrixRuntime } from "../../runtime.js"; import { downloadMatrixMedia } from "./media.js"; @@ -22,12 +22,14 @@ describe("downloadMatrixMedia", () => { setMatrixRuntime(runtimeStub); }); - function makeEncryptedMediaFixture() { + it("decrypts encrypted media when file payloads are present", async () => { const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + const client = { crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + } as unknown as import("../sdk.js").MatrixClient; + const file = { url: "mxc://example/file", key: { @@ -41,11 +43,6 @@ describe("downloadMatrixMedia", () => { hashes: { sha256: "hash" }, v: "v2", }; - return { decryptMedia, client, file }; - } - - it("decrypts encrypted media when file payloads are present", async () => { - const { decryptMedia, client, file } = makeEncryptedMediaFixture(); const result = await downloadMatrixMedia({ client, @@ -55,8 +52,10 @@ describe("downloadMatrixMedia", () => { file, }); - // decryptMedia should be called with just the file object (it handles download internally) - expect(decryptMedia).toHaveBeenCalledWith(file); + expect(decryptMedia).toHaveBeenCalledWith(file, { + maxBytes: 1024, + readIdleTimeoutMs: 30_000, + }); expect(saveMediaBuffer).toHaveBeenCalledWith( Buffer.from("decrypted"), "image/png", @@ -67,7 +66,26 @@ describe("downloadMatrixMedia", () => { }); it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { - const { decryptMedia, client, file } = makeEncryptedMediaFixture(); + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("../sdk.js").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; await expect( downloadMatrixMedia({ @@ -83,4 +101,24 @@ describe("downloadMatrixMedia", () => { expect(decryptMedia).not.toHaveBeenCalled(); expect(saveMediaBuffer).not.toHaveBeenCalled(); }); + + it("passes byte limits through plain media downloads", async () => { + const downloadContent = vi.fn().mockResolvedValue(Buffer.from("plain")); + + const client = { + downloadContent, + } as unknown as import("../sdk.js").MatrixClient; + + await downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + maxBytes: 4096, + }); + + expect(downloadContent).toHaveBeenCalledWith("mxc://example/file", { + maxBytes: 4096, + readIdleTimeoutMs: 30_000, + }); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index baf366186c4..b099554ecee 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -1,5 +1,5 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; // Type for encrypted file info type EncryptedFile = { @@ -16,27 +16,19 @@ type EncryptedFile = { v: string; }; +const MATRIX_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000; + async function fetchMatrixMediaBuffer(params: { client: MatrixClient; mxcUrl: string; maxBytes: number; -}): Promise<{ buffer: Buffer; headerType?: string } | null> { - // @vector-im/matrix-bot-sdk provides mxcToHttp helper - const url = params.client.mxcToHttp(params.mxcUrl); - if (!url) { - return null; - } - - // Use the client's download method which handles auth +}): Promise<{ buffer: Buffer } | null> { try { - const result = await params.client.downloadContent(params.mxcUrl); - const raw = result.data ?? result; - const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); - - if (buffer.byteLength > params.maxBytes) { - throw new Error("Matrix media exceeds configured size limit"); - } - return { buffer, headerType: result.contentType }; + const buffer = await params.client.downloadContent(params.mxcUrl, { + maxBytes: params.maxBytes, + readIdleTimeoutMs: MATRIX_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS, + }); + return { buffer }; } catch (err) { throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err }); } @@ -44,7 +36,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. - * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption. + * Uses the Matrix crypto adapter's decryptMedia helper. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; @@ -55,9 +47,12 @@ async function fetchEncryptedMediaBuffer(params: { throw new Error("Cannot decrypt media: crypto not enabled"); } - // decryptMedia handles downloading and decrypting the encrypted content internally const decrypted = await params.client.crypto.decryptMedia( params.file as Parameters[0], + { + maxBytes: params.maxBytes, + readIdleTimeoutMs: MATRIX_MEDIA_DOWNLOAD_IDLE_TIMEOUT_MS, + }, ); if (decrypted.byteLength > params.maxBytes) { @@ -103,7 +98,7 @@ export async function downloadMatrixMedia(params: { if (!fetched) { return null; } - const headerType = fetched.headerType ?? params.contentType ?? undefined; + const headerType = params.contentType ?? undefined; const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( fetched.buffer, headerType, diff --git a/extensions/matrix/src/matrix/monitor/mentions.test.ts b/extensions/matrix/src/matrix/monitor/mentions.test.ts index f1ee615e7ef..4407b006add 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.test.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.test.ts @@ -19,7 +19,22 @@ describe("resolveMentions", () => { const mentionRegexes = [/@bot/i]; describe("m.mentions field", () => { - it("detects mention via m.mentions.user_ids", () => { + it("detects mention via m.mentions.user_ids when the visible text also mentions the bot", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "hello @bot", + "m.mentions": { user_ids: ["@bot:matrix.org"] }, + }, + userId, + text: "hello @bot", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + expect(result.hasExplicitMention).toBe(true); + }); + + it("does not trust forged m.mentions.user_ids without a visible mention", () => { const result = resolveMentions({ content: { msgtype: "m.text", @@ -30,11 +45,25 @@ describe("resolveMentions", () => { text: "hello", mentionRegexes, }); - expect(result.wasMentioned).toBe(true); - expect(result.hasExplicitMention).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasExplicitMention).toBe(false); }); - it("detects room mention via m.mentions.room", () => { + it("detects room mention via visible @room text", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "@room hello everyone", + "m.mentions": { room: true }, + }, + userId, + text: "@room hello everyone", + mentionRegexes, + }); + expect(result.wasMentioned).toBe(true); + }); + + it("does not trust forged m.mentions.room without visible @room text", () => { const result = resolveMentions({ content: { msgtype: "m.text", @@ -45,7 +74,8 @@ describe("resolveMentions", () => { text: "hello everyone", mentionRegexes, }); - expect(result.wasMentioned).toBe(true); + expect(result.wasMentioned).toBe(false); + expect(result.hasExplicitMention).toBe(false); }); }); @@ -119,6 +149,35 @@ describe("resolveMentions", () => { }); expect(result.wasMentioned).toBe(false); }); + + it("does not trust hidden matrix.to links behind unrelated visible text", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "click here: hello", + formatted_body: 'click here: hello', + }, + userId, + text: "click here: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(false); + }); + + it("detects mention when the visible label still names the bot", () => { + const result = resolveMentions({ + content: { + msgtype: "m.text", + body: "@bot: hello", + formatted_body: + '@bot: hello', + }, + userId, + text: "@bot: hello", + mentionRegexes: [], + }); + expect(result.wasMentioned).toBe(true); + }); }); describe("regex patterns", () => { diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 232e495c88d..a8e5b7b0eb2 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,41 +1,105 @@ import { getMatrixRuntime } from "../../runtime.js"; +import type { RoomMessageEventContent } from "./types.js"; -// Type for room message content with mentions -type MessageContentWithMentions = { - msgtype: string; - body: string; - formatted_body?: string; - "m.mentions"?: { - user_ids?: string[]; - room?: boolean; - }; -}; +function normalizeVisibleMentionText(value: string): string { + return value + .replace(/<[^>]+>/g, " ") + .replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function extractVisibleMentionText(value?: string): string { + return normalizeVisibleMentionText(value ?? ""); +} + +function resolveMatrixUserLocalpart(userId: string): string | null { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + return null; + } + const colonIndex = trimmed.indexOf(":"); + if (colonIndex <= 1) { + return null; + } + return trimmed.slice(1, colonIndex).trim() || null; +} + +function isVisibleMentionLabel(params: { + text: string; + userId: string; + mentionRegexes: RegExp[]; +}): boolean { + const cleaned = extractVisibleMentionText(params.text); + if (!cleaned) { + return false; + } + if (params.mentionRegexes.some((pattern) => pattern.test(cleaned))) { + return true; + } + const localpart = resolveMatrixUserLocalpart(params.userId); + const candidates = [ + params.userId.trim().toLowerCase(), + localpart, + localpart ? `@${localpart}` : null, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + return candidates.includes(cleaned); +} + +function hasVisibleRoomMention(value?: string): boolean { + const cleaned = extractVisibleMentionText(value); + return /(^|[^a-z0-9_])@room\b/i.test(cleaned); +} /** - * Check if the formatted_body contains a matrix.to mention link for the given user ID. + * Check if formatted_body contains a matrix.to link whose visible label still + * looks like a real mention for the given user. Do not trust href alone, since + * senders can hide arbitrary matrix.to links behind unrelated link text. * Many Matrix clients (including Element) use HTML links in formatted_body instead of * or in addition to the m.mentions field. */ -function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean { - if (!formattedBody || !userId) { +function checkFormattedBodyMention(params: { + formattedBody?: string; + userId: string; + mentionRegexes: RegExp[]; +}): boolean { + if (!params.formattedBody || !params.userId) { return false; } - // Escape special regex characters in the user ID (e.g., @user:matrix.org) - const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match matrix.to links with the user ID, handling both URL-encoded and plain formats - // Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org" - const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i"); - if (plainPattern.test(formattedBody)) { - return true; + const anchorPattern = /]*href=(["'])(https:\/\/matrix\.to\/#[^"']+)\1[^>]*>(.*?)<\/a>/gis; + for (const match of params.formattedBody.matchAll(anchorPattern)) { + const href = match[2]; + const visibleLabel = match[3] ?? ""; + if (!href) { + continue; + } + try { + const parsed = new URL(href); + const fragmentTarget = decodeURIComponent(parsed.hash.replace(/^#\/?/, "").trim()); + if (fragmentTarget !== params.userId.trim()) { + continue; + } + if ( + isVisibleMentionLabel({ + text: visibleLabel, + userId: params.userId, + mentionRegexes: params.mentionRegexes, + }) + ) { + return true; + } + } catch { + continue; + } } - // Also check URL-encoded version (@ -> %40, : -> %3A) - const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i"); - return encodedPattern.test(formattedBody); + return false; } export function resolveMentions(params: { - content: MessageContentWithMentions; + content: RoomMessageEventContent; userId?: string | null; text?: string; mentionRegexes: RegExp[]; @@ -44,19 +108,30 @@ export function resolveMentions(params: { const mentionedUsers = Array.isArray(mentions?.user_ids) ? new Set(mentions.user_ids) : new Set(); + const textMentioned = getMatrixRuntime().channel.mentions.matchesMentionPatterns( + params.text ?? "", + params.mentionRegexes, + ); + const visibleRoomMention = + hasVisibleRoomMention(params.text) || hasVisibleRoomMention(params.content.formatted_body); // Check formatted_body for matrix.to mention links (legacy/alternative mention format) const mentionedInFormattedBody = params.userId - ? checkFormattedBodyMention(params.content.formatted_body, params.userId) + ? checkFormattedBodyMention({ + formattedBody: params.content.formatted_body, + userId: params.userId, + mentionRegexes: params.mentionRegexes, + }) : false; + const metadataBackedUserMention = Boolean( + params.userId && + mentionedUsers.has(params.userId) && + (mentionedInFormattedBody || textMentioned), + ); + const metadataBackedRoomMention = Boolean(mentions?.room) && visibleRoomMention; + const explicitMention = + mentionedInFormattedBody || metadataBackedUserMention || metadataBackedRoomMention; - const wasMentioned = - Boolean(mentions?.room) || - (params.userId ? mentionedUsers.has(params.userId) : false) || - mentionedInFormattedBody || - getMatrixRuntime().channel.mentions.matchesMentionPatterns( - params.text ?? "", - params.mentionRegexes, - ); - return { wasMentioned, hasExplicitMention: Boolean(mentions) }; + const wasMentioned = explicitMention || textMentioned || visibleRoomMention; + return { wasMentioned, hasExplicitMention: explicitMention }; } diff --git a/extensions/matrix/src/matrix/monitor/reaction-events.ts b/extensions/matrix/src/matrix/monitor/reaction-events.ts new file mode 100644 index 00000000000..2eef8f06f39 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/reaction-events.ts @@ -0,0 +1,94 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig } from "../../types.js"; +import { resolveMatrixAccountConfig } from "../accounts.js"; +import { extractMatrixReactionAnnotation } from "../reaction-common.js"; +import type { MatrixClient } from "../sdk.js"; +import { resolveMatrixInboundRoute } from "./route.js"; +import { resolveMatrixThreadRootId } from "./threads.js"; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; + +export type MatrixReactionNotificationMode = "off" | "own"; + +export function resolveMatrixReactionNotificationMode(params: { + cfg: CoreConfig; + accountId: string; +}): MatrixReactionNotificationMode { + const matrixConfig = params.cfg.channels?.matrix; + const accountConfig = resolveMatrixAccountConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + return accountConfig.reactionNotifications ?? matrixConfig?.reactionNotifications ?? "own"; +} + +export async function handleInboundMatrixReaction(params: { + client: MatrixClient; + core: PluginRuntime; + cfg: CoreConfig; + accountId: string; + roomId: string; + event: MatrixRawEvent; + senderId: string; + senderLabel: string; + selfUserId: string; + isDirectMessage: boolean; + logVerboseMessage: (message: string) => void; +}): Promise { + const notificationMode = resolveMatrixReactionNotificationMode({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (notificationMode === "off") { + return; + } + + const reaction = extractMatrixReactionAnnotation(params.event.content); + if (!reaction?.eventId) { + return; + } + + const targetEvent = await params.client.getEvent(params.roomId, reaction.eventId).catch((err) => { + params.logVerboseMessage( + `matrix: failed resolving reaction target room=${params.roomId} id=${reaction.eventId}: ${String(err)}`, + ); + return null; + }); + const targetSender = + targetEvent && typeof targetEvent.sender === "string" ? targetEvent.sender.trim() : ""; + if (!targetSender) { + return; + } + if (notificationMode === "own" && targetSender !== params.selfUserId) { + return; + } + + const targetContent = + targetEvent && targetEvent.content && typeof targetEvent.content === "object" + ? (targetEvent.content as RoomMessageEventContent) + : undefined; + const threadRootId = targetContent + ? resolveMatrixThreadRootId({ + event: targetEvent as MatrixRawEvent, + content: targetContent, + }) + : undefined; + const { route } = resolveMatrixInboundRoute({ + cfg: params.cfg, + accountId: params.accountId, + roomId: params.roomId, + senderId: params.senderId, + isDirectMessage: params.isDirectMessage, + messageId: reaction.eventId, + threadRootId, + eventTs: params.event.origin_server_ts, + resolveAgentRoute: params.core.channel.routing.resolveAgentRoute, + }); + const text = `Matrix reaction added: ${reaction.key} by ${params.senderLabel} on msg ${reaction.eventId}`; + params.core.system.enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `matrix:reaction:add:${params.roomId}:${reaction.eventId}:${params.senderId}:${reaction.key}`, + }); + params.logVerboseMessage( + `matrix: reaction event enqueued room=${params.roomId} target=${reaction.eventId} sender=${params.senderId} emoji=${reaction.key}`, + ); +} diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index cc458dc9fe5..33ed0bba226 100644 --- a/extensions/matrix/src/matrix/monitor/replies.test.ts +++ b/extensions/matrix/src/matrix/monitor/replies.test.ts @@ -1,6 +1,6 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js"; +import type { MatrixClient } from "../sdk.js"; const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" })); @@ -13,10 +13,13 @@ import { setMatrixRuntime } from "../../runtime.js"; import { deliverMatrixReplies } from "./replies.js"; describe("deliverMatrixReplies", () => { + const cfg = { channels: { matrix: {} } }; const loadConfigMock = vi.fn(() => ({})); - const resolveMarkdownTableModeMock = vi.fn(() => "code"); + const resolveMarkdownTableModeMock = vi.fn<(params: unknown) => string>(() => "code"); const convertMarkdownTablesMock = vi.fn((text: string) => text); - const resolveChunkModeMock = vi.fn(() => "length"); + const resolveChunkModeMock = vi.fn< + (cfg: unknown, channel: unknown, accountId?: unknown) => string + >(() => "length"); const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]); const runtimeStub = { @@ -25,9 +28,10 @@ describe("deliverMatrixReplies", () => { }, channel: { text: { - resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(), + resolveMarkdownTableMode: (params: unknown) => resolveMarkdownTableModeMock(params), convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text), - resolveChunkMode: () => resolveChunkModeMock(), + resolveChunkMode: (cfg: unknown, channel: unknown, accountId?: unknown) => + resolveChunkModeMock(cfg, channel, accountId), chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text), }, }, @@ -51,6 +55,7 @@ describe("deliverMatrixReplies", () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); await deliverMatrixReplies({ + cfg, replies: [ { text: "first-a|first-b", replyToId: "reply-1" }, { text: "second", replyToId: "reply-2" }, @@ -76,6 +81,7 @@ describe("deliverMatrixReplies", () => { it("keeps replyToId on every reply when replyToMode=all", async () => { await deliverMatrixReplies({ + cfg, replies: [ { text: "caption", @@ -90,80 +96,38 @@ describe("deliverMatrixReplies", () => { runtime: runtimeEnv, textLimit: 4000, replyToMode: "all", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], }); expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3); expect(sendMessageMatrixMock.mock.calls[0]).toEqual([ "room:2", "caption", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }), + expect.objectContaining({ + mediaUrl: "https://example.com/a.jpg", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + replyToId: "reply-media", + }), ]); expect(sendMessageMatrixMock.mock.calls[1]).toEqual([ "room:2", "", - expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }), + expect.objectContaining({ + mediaUrl: "https://example.com/b.jpg", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + replyToId: "reply-media", + }), ]); expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual( expect.objectContaining({ replyToId: "reply-text" }), ); }); - it("skips reasoning-only replies with Reasoning prefix", async () => { - await deliverMatrixReplies({ - replies: [ - { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" }, - { text: "Here is the answer.", replyToId: "r2" }, - ], - roomId: "room:reason", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "first", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); - expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer."); - }); - - it("skips reasoning-only replies with thinking tags", async () => { - await deliverMatrixReplies({ - replies: [ - { text: "internal chain of thought", replyToId: "r1" }, - { text: " more reasoning ", replyToId: "r2" }, - { text: "hidden", replyToId: "r3" }, - { text: "Visible reply", replyToId: "r4" }, - ], - roomId: "room:tags", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "all", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); - expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply"); - }); - - it("delivers all replies when none are reasoning-only", async () => { - await deliverMatrixReplies({ - replies: [ - { text: "First answer", replyToId: "r1" }, - { text: "Second answer", replyToId: "r2" }, - ], - roomId: "room:normal", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "all", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); - }); - it("suppresses replyToId when threadId is set", async () => { chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); await deliverMatrixReplies({ + cfg, replies: [{ text: "hello|thread", replyToId: "reply-thread" }], roomId: "room:3", client: {} as MatrixClient, @@ -181,4 +145,67 @@ describe("deliverMatrixReplies", () => { expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }), ); }); + + it("suppresses reasoning-only text before Matrix sends", async () => { + await deliverMatrixReplies({ + cfg, + replies: [ + { text: "Reasoning:\n_hidden_" }, + { text: "still hidden" }, + { text: "Visible answer" }, + ], + roomId: "room:5", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "off", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(sendMessageMatrixMock).toHaveBeenCalledWith( + "room:5", + "Visible answer", + expect.objectContaining({ cfg }), + ); + }); + + it("uses supplied cfg for chunking and send delivery without reloading runtime config", async () => { + const explicitCfg = { + channels: { + matrix: { + accounts: { + ops: { + chunkMode: "newline", + }, + }, + }, + }, + }; + loadConfigMock.mockImplementation(() => { + throw new Error("deliverMatrixReplies should not reload runtime config when cfg is provided"); + }); + + await deliverMatrixReplies({ + cfg: explicitCfg, + replies: [{ text: "hello", replyToId: "reply-1" }], + roomId: "room:4", + client: {} as MatrixClient, + runtime: runtimeEnv, + textLimit: 4000, + replyToMode: "all", + accountId: "ops", + }); + + expect(loadConfigMock).not.toHaveBeenCalled(); + expect(resolveChunkModeMock).toHaveBeenCalledWith(explicitCfg, "matrix", "ops"); + expect(sendMessageMatrixMock).toHaveBeenCalledWith( + "room:4", + "hello", + expect.objectContaining({ + cfg: explicitCfg, + accountId: "ops", + replyToId: "reply-1", + }), + ); + }); }); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index dac58c680ed..8874b688591 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,13 +1,40 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { - deliverTextOrMediaReply, - resolveSendableOutboundReplyParts, -} from "openclaw/plugin-sdk/reply-payload"; -import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; +import type { + MarkdownTableMode, + OpenClawConfig, + ReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; import { sendMessageMatrix } from "../send.js"; +const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi; +const THINKING_BLOCK_RE = + /<\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi; + +function shouldSuppressReasoningReplyText(text?: string): boolean { + if (typeof text !== "string") { + return false; + } + const trimmedStart = text.trimStart(); + if (!trimmedStart) { + return false; + } + if (trimmedStart.toLowerCase().startsWith("reasoning:")) { + return true; + } + THINKING_TAG_RE.lastIndex = 0; + if (!THINKING_TAG_RE.test(text)) { + return false; + } + THINKING_BLOCK_RE.lastIndex = 0; + const withoutThinkingBlocks = text.replace(THINKING_BLOCK_RE, ""); + THINKING_TAG_RE.lastIndex = 0; + return !withoutThinkingBlocks.replace(THINKING_TAG_RE, "").trim(); +} + export async function deliverMatrixReplies(params: { + cfg: OpenClawConfig; replies: ReplyPayload[]; roomId: string; client: MatrixClient; @@ -16,14 +43,14 @@ export async function deliverMatrixReplies(params: { replyToMode: "off" | "first" | "all"; threadId?: string; accountId?: string; + mediaLocalRoots?: readonly string[]; tableMode?: MarkdownTableMode; }): Promise { const core = getMatrixRuntime(); - const cfg = core.config.loadConfig(); const tableMode = params.tableMode ?? core.channel.text.resolveMarkdownTableMode({ - cfg, + cfg: params.cfg, channel: "matrix", accountId: params.accountId, }); @@ -33,13 +60,15 @@ export async function deliverMatrixReplies(params: { } }; const chunkLimit = Math.min(params.textLimit, 4000); - const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId); + const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "matrix", params.accountId); let hasReplied = false; for (const reply of params.replies) { - const rawText = reply.text ?? ""; - const text = core.channel.text.convertMarkdownTables(rawText, tableMode); - const replyContent = resolveSendableOutboundReplyParts(reply, { text }); - if (!replyContent.hasContent) { + if (reply.isReasoning === true || shouldSuppressReasoningReplyText(reply.text)) { + logVerbose("matrix reply suppressed as reasoning-only"); + continue; + } + const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; + if (!reply?.text && !hasMedia) { if (reply?.audioAsVoice) { logVerbose("matrix reply has audioAsVoice without media/text; skipping"); continue; @@ -47,66 +76,63 @@ export async function deliverMatrixReplies(params: { params.runtime.error?.("matrix reply missing text/media"); continue; } - // Skip pure reasoning messages so internal thinking traces are never delivered. - if (reply.text && isReasoningOnlyMessage(reply.text)) { - logVerbose("matrix reply is reasoning-only; skipping"); - continue; - } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); + const mediaList = reply.mediaUrls?.length + ? reply.mediaUrls + : reply.mediaUrl + ? [reply.mediaUrl] + : []; const shouldIncludeReply = (id?: string) => Boolean(id) && (params.replyToMode === "all" || !hasReplied); const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; - const delivered = await deliverTextOrMediaReply({ - payload: reply, - text: replyContent.text, - chunkText: (value) => - core.channel.text - .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode) - .map((chunk) => chunk.trim()) - .filter(Boolean), - sendText: async (trimmed) => { + if (mediaList.length === 0) { + let sentTextChunk = false; + for (const chunk of core.channel.text.chunkMarkdownTextWithMode( + text, + chunkLimit, + chunkMode, + )) { + const trimmed = chunk.trim(); + if (!trimmed) { + continue; + } await sendMessageMatrix(params.roomId, trimmed, { client: params.client, + cfg: params.cfg, replyToId: replyToIdForReply, threadId: params.threadId, accountId: params.accountId, }); - }, - sendMedia: async ({ mediaUrl, caption }) => { - await sendMessageMatrix(params.roomId, caption ?? "", { - client: params.client, - mediaUrl, - replyToId: replyToIdForReply, - threadId: params.threadId, - audioAsVoice: reply.audioAsVoice, - accountId: params.accountId, - }); - }, - }); - if (replyToIdForReply && !hasReplied && delivered !== "empty") { + sentTextChunk = true; + } + if (replyToIdForReply && !hasReplied && sentTextChunk) { + hasReplied = true; + } + continue; + } + + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : ""; + await sendMessageMatrix(params.roomId, caption, { + client: params.client, + cfg: params.cfg, + mediaUrl, + mediaLocalRoots: params.mediaLocalRoots, + replyToId: replyToIdForReply, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + first = false; + } + if (replyToIdForReply && !hasReplied) { hasReplied = true; } } } - -const REASONING_PREFIX = "Reasoning:\n"; -const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i; - -/** - * Detect messages that contain only reasoning/thinking content and no user-facing answer. - * These are emitted by the agent when `includeReasoning` is active but should not - * be forwarded to channels that do not support a dedicated reasoning lane. - */ -function isReasoningOnlyMessage(text: string): boolean { - const trimmed = text.trim(); - if (trimmed.startsWith(REASONING_PREFIX)) { - return true; - } - if (THINKING_TAG_RE.test(trimmed)) { - return true; - } - return false; -} diff --git a/extensions/matrix/src/matrix/monitor/room-info.test.ts b/extensions/matrix/src/matrix/monitor/room-info.test.ts new file mode 100644 index 00000000000..0cfb3c4ab1c --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/room-info.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomInfoResolver } from "./room-info.js"; + +function createClientStub() { + return { + getRoomStateEvent: vi.fn( + async ( + roomId: string, + eventType: string, + stateKey: string, + ): Promise> => { + if (eventType === "m.room.name") { + return { name: `Room ${roomId}` }; + } + if (eventType === "m.room.canonical_alias") { + return { + alias: `#alias-${roomId}:example.org`, + alt_aliases: [`#alt-${roomId}:example.org`], + }; + } + if (eventType === "m.room.member") { + return { displayname: `Display ${roomId}:${stateKey}` }; + } + return {}; + }, + ), + } as unknown as MatrixClient & { + getRoomStateEvent: ReturnType; + }; +} + +describe("createMatrixRoomInfoResolver", () => { + it("caches room names and member display names, and loads aliases only on demand", async () => { + const client = createClientStub(); + const resolver = createMatrixRoomInfoResolver(client); + + await resolver.getRoomInfo("!room:example.org"); + await resolver.getRoomInfo("!room:example.org"); + await resolver.getRoomInfo("!room:example.org", { includeAliases: true }); + await resolver.getRoomInfo("!room:example.org", { includeAliases: true }); + await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); + await resolver.getMemberDisplayName("!room:example.org", "@alice:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(3); + }); + + it("bounds cached room and member entries", async () => { + const client = createClientStub(); + const resolver = createMatrixRoomInfoResolver(client); + + for (let i = 0; i <= 1024; i += 1) { + await resolver.getRoomInfo(`!room-${i}:example.org`); + } + await resolver.getRoomInfo("!room-0:example.org"); + + for (let i = 0; i <= 4096; i += 1) { + await resolver.getMemberDisplayName("!room:example.org", `@user-${i}:example.org`); + } + await resolver.getMemberDisplayName("!room:example.org", "@user-0:example.org"); + + expect(client.getRoomStateEvent).toHaveBeenCalledTimes(5124); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 764147d3539..cbfc4b173b5 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "../sdk.js"; export type MatrixRoomInfo = { name?: string; @@ -6,43 +6,101 @@ export type MatrixRoomInfo = { altAliases: string[]; }; -export function createMatrixRoomInfoResolver(client: MatrixClient) { - const roomInfoCache = new Map(); +const MAX_TRACKED_ROOM_INFO = 1024; +const MAX_TRACKED_MEMBER_DISPLAY_NAMES = 4096; - const getRoomInfo = async (roomId: string): Promise => { - const cached = roomInfoCache.get(roomId); - if (cached) { - return cached; +function rememberBounded(map: Map, key: string, value: T, maxEntries: number): void { + map.set(key, value); + if (map.size > maxEntries) { + const oldest = map.keys().next().value; + if (typeof oldest === "string") { + map.delete(oldest); + } + } +} + +export function createMatrixRoomInfoResolver(client: MatrixClient) { + const roomNameCache = new Map(); + const roomAliasCache = new Map>(); + const memberDisplayNameCache = new Map(); + + const getRoomName = async (roomId: string): Promise => { + if (roomNameCache.has(roomId)) { + return roomNameCache.get(roomId); } let name: string | undefined; - let canonicalAlias: string | undefined; - let altAliases: string[] = []; try { const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null); - name = nameState?.name; + if (nameState && typeof nameState.name === "string") { + name = nameState.name; + } } catch { // ignore } + rememberBounded(roomNameCache, roomId, name, MAX_TRACKED_ROOM_INFO); + return name; + }; + + const getRoomAliases = async ( + roomId: string, + ): Promise> => { + const cached = roomAliasCache.get(roomId); + if (cached) { + return cached; + } + let canonicalAlias: string | undefined; + let altAliases: string[] = []; try { const aliasState = await client .getRoomStateEvent(roomId, "m.room.canonical_alias", "") .catch(() => null); - canonicalAlias = aliasState?.alias; - altAliases = aliasState?.alt_aliases ?? []; + if (aliasState && typeof aliasState.alias === "string") { + canonicalAlias = aliasState.alias; + } + const rawAliases = aliasState?.alt_aliases; + if (Array.isArray(rawAliases)) { + altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string"); + } } catch { // ignore } - const info = { name, canonicalAlias, altAliases }; - roomInfoCache.set(roomId, info); + const info = { canonicalAlias, altAliases }; + rememberBounded(roomAliasCache, roomId, info, MAX_TRACKED_ROOM_INFO); return info; }; + const getRoomInfo = async ( + roomId: string, + opts: { includeAliases?: boolean } = {}, + ): Promise => { + const name = await getRoomName(roomId); + if (!opts.includeAliases) { + return { name, altAliases: [] }; + } + const aliases = await getRoomAliases(roomId); + return { name, ...aliases }; + }; + const getMemberDisplayName = async (roomId: string, userId: string): Promise => { + const cacheKey = `${roomId}:${userId}`; + const cached = memberDisplayNameCache.get(cacheKey); + if (cached) { + return cached; + } try { const memberState = await client .getRoomStateEvent(roomId, "m.room.member", userId) .catch(() => null); - return memberState?.displayname ?? userId; + if (memberState && typeof memberState.displayname === "string") { + rememberBounded( + memberDisplayNameCache, + cacheKey, + memberState.displayname, + MAX_TRACKED_MEMBER_DISPLAY_NAMES, + ); + return memberState.displayname; + } + return userId; } catch { return userId; } diff --git a/extensions/matrix/src/matrix/monitor/rooms.test.ts b/extensions/matrix/src/matrix/monitor/rooms.test.ts index 9c94dc49ce0..6ee158cd302 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.test.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.test.ts @@ -13,7 +13,6 @@ describe("resolveMatrixRoomConfig", () => { rooms, roomId: "!room:example.org", aliases: [], - name: "Project Room", }); expect(byId.allowed).toBe(true); expect(byId.matchKey).toBe("!room:example.org"); @@ -22,7 +21,6 @@ describe("resolveMatrixRoomConfig", () => { rooms, roomId: "!other:example.org", aliases: ["#alias:example.org"], - name: "Other Room", }); expect(byAlias.allowed).toBe(true); expect(byAlias.matchKey).toBe("#alias:example.org"); @@ -31,7 +29,6 @@ describe("resolveMatrixRoomConfig", () => { rooms: { "Project Room": { allow: true } }, roomId: "!different:example.org", aliases: [], - name: "Project Room", }); expect(byName.allowed).toBe(false); expect(byName.config).toBeUndefined(); diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts index 270320f6e12..828a1f56955 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -1,4 +1,4 @@ -import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "../../../runtime-api.js"; +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix"; import type { MatrixRoomConfig } from "../../types.js"; export type MatrixRoomConfigResolved = { @@ -13,7 +13,6 @@ export function resolveMatrixRoomConfig(params: { rooms?: Record; roomId: string; aliases: string[]; - name?: string | null; }): MatrixRoomConfigResolved { const rooms = params.rooms ?? {}; const keys = Object.keys(rooms); diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts new file mode 100644 index 00000000000..3b64f3e4491 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../../../src/config/config.js"; +import { + __testing as sessionBindingTesting, + registerSessionBindingAdapter, +} from "../../../../../src/infra/outbound/session-binding-service.js"; +import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js"; +import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; +import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js"; +import { matrixPlugin } from "../../channel.js"; +import { resolveMatrixInboundRoute } from "./route.js"; + +const baseCfg = { + session: { mainKey: "main" }, + agents: { + list: [{ id: "main" }, { id: "sender-agent" }, { id: "room-agent" }, { id: "acp-agent" }], + }, +} satisfies OpenClawConfig; + +function resolveDmRoute(cfg: OpenClawConfig) { + return resolveMatrixInboundRoute({ + cfg, + accountId: "ops", + roomId: "!dm:example.org", + senderId: "@alice:example.org", + isDirectMessage: true, + messageId: "$msg1", + resolveAgentRoute, + }); +} + +describe("resolveMatrixInboundRoute", () => { + beforeEach(() => { + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix", source: "test", plugin: matrixPlugin }]), + ); + }); + + it("prefers sender-bound DM routing over DM room fallback bindings", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + { + agentId: "sender-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "direct", id: "@alice:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("sender-agent"); + expect(route.matchedBy).toBe("binding.peer"); + expect(route.sessionKey).toBe("agent:sender-agent:main"); + }); + + it("uses the DM room as a parent-peer fallback before account-level bindings", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "acp-agent", + match: { + channel: "matrix", + accountId: "ops", + }, + }, + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("room-agent"); + expect(route.matchedBy).toBe("binding.peer.parent"); + expect(route.sessionKey).toBe("agent:room-agent:main"); + }); + + it("lets configured ACP room bindings override DM parent-peer routing", () => { + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + { + type: "acp", + agentId: "acp-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding?.spec.agentId).toBe("acp-agent"); + expect(route.agentId).toBe("acp-agent"); + expect(route.matchedBy).toBe("binding.channel"); + expect(route.sessionKey).toContain("agent:acp-agent:acp:binding:matrix:ops:"); + }); + + it("lets runtime conversation bindings override both sender and room route matches", () => { + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "ops", + listBySession: () => [], + resolveByConversation: (ref) => + ref.conversationId === "!dm:example.org" + ? { + bindingId: "ops:!dm:example.org", + targetSessionKey: "agent:bound:session-1", + targetKind: "session", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "!dm:example.org", + }, + status: "active", + boundAt: Date.now(), + metadata: { boundBy: "user-1" }, + } + : null, + touch: vi.fn(), + }); + + const cfg = { + ...baseCfg, + bindings: [ + { + agentId: "sender-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "direct", id: "@alice:example.org" }, + }, + }, + { + agentId: "room-agent", + match: { + channel: "matrix", + accountId: "ops", + peer: { kind: "channel", id: "!dm:example.org" }, + }, + }, + ], + } satisfies OpenClawConfig; + + const { route, configuredBinding } = resolveDmRoute(cfg); + + expect(configuredBinding).toBeNull(); + expect(route.agentId).toBe("bound"); + expect(route.matchedBy).toBe("binding.channel"); + expect(route.sessionKey).toBe("agent:bound:session-1"); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts new file mode 100644 index 00000000000..5144f11bd59 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -0,0 +1,99 @@ +import { + getSessionBindingService, + resolveAgentIdFromSessionKey, + resolveConfiguredAcpBindingRecord, + type PluginRuntime, +} from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig } from "../../types.js"; + +type MatrixResolvedRoute = ReturnType; + +export function resolveMatrixInboundRoute(params: { + cfg: CoreConfig; + accountId: string; + roomId: string; + senderId: string; + isDirectMessage: boolean; + messageId: string; + threadRootId?: string; + eventTs?: number; + resolveAgentRoute: PluginRuntime["channel"]["routing"]["resolveAgentRoute"]; +}): { + route: MatrixResolvedRoute; + configuredBinding: ReturnType; +} { + const baseRoute = params.resolveAgentRoute({ + cfg: params.cfg, + channel: "matrix", + accountId: params.accountId, + peer: { + kind: params.isDirectMessage ? "direct" : "channel", + id: params.isDirectMessage ? params.senderId : params.roomId, + }, + // Matrix DMs are still sender-addressed first, but the room ID remains a + // useful fallback binding key for generic route matching. + parentPeer: params.isDirectMessage + ? { + kind: "channel", + id: params.roomId, + } + : undefined, + }); + const bindingConversationId = + params.threadRootId && params.threadRootId !== params.messageId + ? params.threadRootId + : params.roomId; + const bindingParentConversationId = + bindingConversationId === params.roomId ? undefined : params.roomId; + const sessionBindingService = getSessionBindingService(); + const runtimeBinding = sessionBindingService.resolveByConversation({ + channel: "matrix", + accountId: params.accountId, + conversationId: bindingConversationId, + parentConversationId: bindingParentConversationId, + }); + const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); + + if (runtimeBinding) { + sessionBindingService.touch(runtimeBinding.bindingId, params.eventTs); + } + if (runtimeBinding && boundSessionKey) { + return { + route: { + ...baseRoute, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey) || baseRoute.agentId, + matchedBy: "binding.channel", + }, + configuredBinding: null, + }; + } + + const configuredBinding = + runtimeBinding == null + ? resolveConfiguredAcpBindingRecord({ + cfg: params.cfg, + channel: "matrix", + accountId: params.accountId, + conversationId: bindingConversationId, + parentConversationId: bindingParentConversationId, + }) + : null; + const configuredSessionKey = configuredBinding?.record.targetSessionKey?.trim(); + + return { + route: + configuredBinding && configuredSessionKey + ? { + ...baseRoute, + sessionKey: configuredSessionKey, + agentId: + resolveAgentIdFromSessionKey(configuredSessionKey) || + configuredBinding.spec.agentId || + baseRoute.agentId, + matchedBy: "binding.channel", + } + : baseRoute, + configuredBinding, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts new file mode 100644 index 00000000000..88a53106287 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { ensureMatrixStartupVerification } from "./startup-verification.js"; + +function createTempStateDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-startup-verify-")); +} + +function createStateFilePath(rootDir: string): string { + return path.join(rootDir, "startup-verification.json"); +} + +function createAuth(accountId = "default") { + return { + accountId, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: true, + }; +} + +type VerificationSummaryLike = { + id: string; + transactionId?: string; + isSelfVerification: boolean; + completed: boolean; + pending: boolean; +}; + +function createHarness(params?: { + verified?: boolean; + localVerified?: boolean; + crossSigningVerified?: boolean; + signedByOwner?: boolean; + requestVerification?: () => Promise<{ id: string; transactionId?: string }>; + listVerifications?: () => Promise; +}) { + const requestVerification = + params?.requestVerification ?? + (async () => ({ + id: "verification-1", + transactionId: "txn-1", + })); + const listVerifications = params?.listVerifications ?? (async () => []); + const getOwnDeviceVerificationStatus = vi.fn(async () => ({ + encryptionEnabled: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + verified: params?.verified === true, + localVerified: params?.localVerified ?? params?.verified === true, + crossSigningVerified: params?.crossSigningVerified ?? params?.verified === true, + signedByOwner: params?.signedByOwner ?? params?.verified === true, + recoveryKeyStored: false, + recoveryKeyCreatedAt: null, + recoveryKeyId: null, + backupVersion: null, + backup: { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }, + })); + return { + client: { + getOwnDeviceVerificationStatus, + crypto: { + listVerifications: vi.fn(listVerifications), + requestVerification: vi.fn(requestVerification), + }, + }, + getOwnDeviceVerificationStatus, + }; +} + +describe("ensureMatrixStartupVerification", () => { + it("skips automatic requests when the device is already verified", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness({ verified: true }); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + }); + + expect(result.kind).toBe("verified"); + expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled(); + }); + + it("still requests startup verification when trust is only local", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness({ + verified: false, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + }); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + }); + + expect(result.kind).toBe("requested"); + expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true }); + }); + + it("skips automatic requests when a self verification is already pending", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness({ + listVerifications: async () => [ + { + id: "verification-1", + transactionId: "txn-1", + isSelfVerification: true, + completed: false, + pending: true, + }, + ], + }); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + }); + + expect(result.kind).toBe("pending"); + expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled(); + }); + + it("respects the startup verification cooldown", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness(); + const initialNowMs = Date.parse("2026-03-08T12:00:00.000Z"); + await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + nowMs: initialNowMs, + }); + expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1); + + const second = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + nowMs: initialNowMs + 60_000, + }); + + expect(second.kind).toBe("cooldown"); + expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1); + }); + + it("supports disabling startup verification requests", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness(); + const stateFilePath = createStateFilePath(tempHome); + fs.writeFileSync(stateFilePath, JSON.stringify({ attemptedAt: "2026-03-08T12:00:00.000Z" })); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: { + startupVerification: "off", + }, + stateFilePath, + }); + + expect(result.kind).toBe("disabled"); + expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled(); + expect(fs.existsSync(stateFilePath)).toBe(false); + }); + + it("persists a successful startup verification request", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness(); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + nowMs: Date.parse("2026-03-08T12:00:00.000Z"), + }); + + expect(result.kind).toBe("requested"); + expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true }); + + expect(fs.existsSync(createStateFilePath(tempHome))).toBe(true); + }); + + it("keeps startup verification failures non-fatal", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness({ + requestVerification: async () => { + throw new Error("no other verified session"); + }, + }); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + }); + + expect(result.kind).toBe("request-failed"); + if (result.kind !== "request-failed") { + throw new Error(`Unexpected startup verification result: ${result.kind}`); + } + expect(result.error).toContain("no other verified session"); + + const cooledDown = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + nowMs: Date.now() + 60_000, + }); + + expect(cooledDown.kind).toBe("cooldown"); + }); + + it("retries failed startup verification requests sooner than successful ones", async () => { + const tempHome = createTempStateDir(); + const stateFilePath = createStateFilePath(tempHome); + const failingHarness = createHarness({ + requestVerification: async () => { + throw new Error("no other verified session"); + }, + }); + + await ensureMatrixStartupVerification({ + client: failingHarness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath, + nowMs: Date.parse("2026-03-08T12:00:00.000Z"), + }); + + const retryingHarness = createHarness(); + const result = await ensureMatrixStartupVerification({ + client: retryingHarness.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath, + nowMs: Date.parse("2026-03-08T13:30:00.000Z"), + }); + + expect(result.kind).toBe("requested"); + expect(retryingHarness.client.crypto.requestVerification).toHaveBeenCalledTimes(1); + }); + + it("clears the persisted startup state after verification succeeds", async () => { + const tempHome = createTempStateDir(); + const stateFilePath = createStateFilePath(tempHome); + const unverified = createHarness(); + + await ensureMatrixStartupVerification({ + client: unverified.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath, + nowMs: Date.parse("2026-03-08T12:00:00.000Z"), + }); + + expect(fs.existsSync(stateFilePath)).toBe(true); + + const verified = createHarness({ verified: true }); + const result = await ensureMatrixStartupVerification({ + client: verified.client as never, + auth: createAuth(), + accountConfig: {}, + stateFilePath, + }); + + expect(result.kind).toBe("verified"); + expect(fs.existsSync(stateFilePath)).toBe(false); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.ts b/extensions/matrix/src/matrix/monitor/startup-verification.ts new file mode 100644 index 00000000000..6bc34136674 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup-verification.ts @@ -0,0 +1,237 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix"; +import type { MatrixConfig } from "../../types.js"; +import { resolveMatrixStoragePaths } from "../client/storage.js"; +import type { MatrixAuth } from "../client/types.js"; +import type { MatrixClient, MatrixOwnDeviceVerificationStatus } from "../sdk.js"; + +const STARTUP_VERIFICATION_STATE_FILENAME = "startup-verification.json"; +const DEFAULT_STARTUP_VERIFICATION_MODE = "if-unverified" as const; +const DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS = 24; +const DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS = 60 * 60 * 1000; + +type MatrixStartupVerificationState = { + userId?: string | null; + deviceId?: string | null; + attemptedAt?: string; + outcome?: "requested" | "failed"; + requestId?: string; + transactionId?: string; + error?: string; +}; + +export type MatrixStartupVerificationOutcome = + | { + kind: "disabled" | "verified" | "cooldown" | "pending" | "requested" | "request-failed"; + verification: MatrixOwnDeviceVerificationStatus; + requestId?: string; + transactionId?: string; + error?: string; + retryAfterMs?: number; + } + | { + kind: "unsupported"; + verification?: undefined; + }; + +function normalizeCooldownHours(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS; + } + return Math.max(0, value); +} + +function resolveStartupVerificationStatePath(params: { + auth: MatrixAuth; + env?: NodeJS.ProcessEnv; +}): string { + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.auth.accountId, + deviceId: params.auth.deviceId, + env: params.env, + }); + return path.join(storagePaths.rootDir, STARTUP_VERIFICATION_STATE_FILENAME); +} + +async function readStartupVerificationState( + filePath: string, +): Promise { + const { value } = await readJsonFileWithFallback( + filePath, + null, + ); + return value && typeof value === "object" ? value : null; +} + +async function clearStartupVerificationState(filePath: string): Promise { + await fs.rm(filePath, { force: true }).catch(() => {}); +} + +function resolveStateCooldownMs( + state: MatrixStartupVerificationState | null, + cooldownMs: number, +): number { + if (state?.outcome === "failed") { + return Math.min(cooldownMs, DEFAULT_STARTUP_VERIFICATION_FAILURE_COOLDOWN_MS); + } + return cooldownMs; +} + +function resolveRetryAfterMs(params: { + attemptedAt?: string; + cooldownMs: number; + nowMs: number; +}): number | undefined { + const attemptedAtMs = Date.parse(params.attemptedAt ?? ""); + if (!Number.isFinite(attemptedAtMs)) { + return undefined; + } + const remaining = attemptedAtMs + params.cooldownMs - params.nowMs; + return remaining > 0 ? remaining : undefined; +} + +function shouldHonorCooldown(params: { + state: MatrixStartupVerificationState | null; + verification: MatrixOwnDeviceVerificationStatus; + stateCooldownMs: number; + nowMs: number; +}): boolean { + if (!params.state || params.stateCooldownMs <= 0) { + return false; + } + if ( + params.state.userId && + params.verification.userId && + params.state.userId !== params.verification.userId + ) { + return false; + } + if ( + params.state.deviceId && + params.verification.deviceId && + params.state.deviceId !== params.verification.deviceId + ) { + return false; + } + return ( + resolveRetryAfterMs({ + attemptedAt: params.state.attemptedAt, + cooldownMs: params.stateCooldownMs, + nowMs: params.nowMs, + }) !== undefined + ); +} + +function hasPendingSelfVerification( + verifications: Array<{ + isSelfVerification: boolean; + completed: boolean; + pending: boolean; + }>, +): boolean { + return verifications.some( + (entry) => + entry.isSelfVerification === true && entry.completed !== true && entry.pending !== false, + ); +} + +export async function ensureMatrixStartupVerification(params: { + client: Pick; + auth: MatrixAuth; + accountConfig: Pick; + env?: NodeJS.ProcessEnv; + nowMs?: number; + stateFilePath?: string; +}): Promise { + if (params.auth.encryption !== true || !params.client.crypto) { + return { kind: "unsupported" }; + } + + const verification = await params.client.getOwnDeviceVerificationStatus(); + const statePath = + params.stateFilePath ?? + resolveStartupVerificationStatePath({ + auth: params.auth, + env: params.env, + }); + + if (verification.verified) { + await clearStartupVerificationState(statePath); + return { + kind: "verified", + verification, + }; + } + + const mode = params.accountConfig.startupVerification ?? DEFAULT_STARTUP_VERIFICATION_MODE; + if (mode === "off") { + await clearStartupVerificationState(statePath); + return { + kind: "disabled", + verification, + }; + } + + const verifications = await params.client.crypto.listVerifications().catch(() => []); + if (hasPendingSelfVerification(verifications)) { + return { + kind: "pending", + verification, + }; + } + + const cooldownHours = normalizeCooldownHours( + params.accountConfig.startupVerificationCooldownHours, + ); + const cooldownMs = cooldownHours * 60 * 60 * 1000; + const nowMs = params.nowMs ?? Date.now(); + const state = await readStartupVerificationState(statePath); + const stateCooldownMs = resolveStateCooldownMs(state, cooldownMs); + if (shouldHonorCooldown({ state, verification, stateCooldownMs, nowMs })) { + return { + kind: "cooldown", + verification, + retryAfterMs: resolveRetryAfterMs({ + attemptedAt: state?.attemptedAt, + cooldownMs: stateCooldownMs, + nowMs, + }), + }; + } + + try { + const request = await params.client.crypto.requestVerification({ ownUser: true }); + await writeJsonFileAtomically(statePath, { + userId: verification.userId, + deviceId: verification.deviceId, + attemptedAt: new Date(nowMs).toISOString(), + outcome: "requested", + requestId: request.id, + transactionId: request.transactionId, + } satisfies MatrixStartupVerificationState); + return { + kind: "requested", + verification, + requestId: request.id, + transactionId: request.transactionId ?? undefined, + }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + await writeJsonFileAtomically(statePath, { + userId: verification.userId, + deviceId: verification.deviceId, + attemptedAt: new Date(nowMs).toISOString(), + outcome: "failed", + error, + } satisfies MatrixStartupVerificationState).catch(() => {}); + return { + kind: "request-failed", + verification, + error, + }; + } +} diff --git a/extensions/matrix/src/matrix/monitor/startup.test.ts b/extensions/matrix/src/matrix/monitor/startup.test.ts new file mode 100644 index 00000000000..44d328fb811 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixProfileSyncResult } from "../profile.js"; +import type { MatrixOwnDeviceVerificationStatus } from "../sdk.js"; +import type { MatrixLegacyCryptoRestoreResult } from "./legacy-crypto-restore.js"; +import type { MatrixStartupVerificationOutcome } from "./startup-verification.js"; +import { runMatrixStartupMaintenance } from "./startup.js"; + +function createVerificationStatus( + overrides: Partial = {}, +): MatrixOwnDeviceVerificationStatus { + return { + encryptionEnabled: true, + userId: "@bot:example.org", + deviceId: "DEVICE", + verified: false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + recoveryKeyStored: false, + recoveryKeyCreatedAt: null, + recoveryKeyId: null, + backupVersion: null, + backup: { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }, + ...overrides, + }; +} + +function createProfileSyncResult( + overrides: Partial = {}, +): MatrixProfileSyncResult { + return { + skipped: false, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + ...overrides, + }; +} + +function createStartupVerificationOutcome( + kind: Exclude, + overrides: Partial> = {}, +): MatrixStartupVerificationOutcome { + return { + kind, + verification: createVerificationStatus({ verified: kind === "verified" }), + ...overrides, + } as MatrixStartupVerificationOutcome; +} + +function createLegacyCryptoRestoreResult( + overrides: Partial = {}, +): MatrixLegacyCryptoRestoreResult { + return { + kind: "skipped", + ...overrides, + } as MatrixLegacyCryptoRestoreResult; +} + +const hoisted = vi.hoisted(() => ({ + maybeRestoreLegacyMatrixBackup: vi.fn(async () => createLegacyCryptoRestoreResult()), + summarizeMatrixDeviceHealth: vi.fn(() => ({ + staleOpenClawDevices: [] as Array<{ deviceId: string }>, + })), + syncMatrixOwnProfile: vi.fn(async () => createProfileSyncResult()), + ensureMatrixStartupVerification: vi.fn(async () => createStartupVerificationOutcome("verified")), + updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg), +})); + +vi.mock("../config-update.js", () => ({ + updateMatrixAccountConfig: hoisted.updateMatrixAccountConfig, +})); + +vi.mock("../device-health.js", () => ({ + summarizeMatrixDeviceHealth: hoisted.summarizeMatrixDeviceHealth, +})); + +vi.mock("../profile.js", () => ({ + syncMatrixOwnProfile: hoisted.syncMatrixOwnProfile, +})); + +vi.mock("./legacy-crypto-restore.js", () => ({ + maybeRestoreLegacyMatrixBackup: hoisted.maybeRestoreLegacyMatrixBackup, +})); + +vi.mock("./startup-verification.js", () => ({ + ensureMatrixStartupVerification: hoisted.ensureMatrixStartupVerification, +})); + +describe("runMatrixStartupMaintenance", () => { + beforeEach(() => { + hoisted.maybeRestoreLegacyMatrixBackup + .mockClear() + .mockResolvedValue(createLegacyCryptoRestoreResult()); + hoisted.summarizeMatrixDeviceHealth.mockClear().mockReturnValue({ staleOpenClawDevices: [] }); + hoisted.syncMatrixOwnProfile.mockClear().mockResolvedValue(createProfileSyncResult()); + hoisted.ensureMatrixStartupVerification + .mockClear() + .mockResolvedValue(createStartupVerificationOutcome("verified")); + hoisted.updateMatrixAccountConfig.mockClear().mockImplementation((cfg: unknown) => cfg); + }); + + function createParams(): Parameters[0] { + return { + client: { + crypto: {}, + listOwnDevices: vi.fn(async () => []), + getOwnDeviceVerificationStatus: vi.fn(async () => createVerificationStatus()), + } as never, + auth: { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: false, + }, + accountId: "ops", + effectiveAccountId: "ops", + accountConfig: { + name: "Ops Bot", + avatarUrl: "https://example.org/avatar.png", + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, + logVerboseMessage: vi.fn(), + loadConfig: vi.fn(() => ({ channels: { matrix: {} } })), + writeConfigFile: vi.fn(async () => {}), + loadWebMedia: vi.fn(async () => ({ + buffer: Buffer.from("avatar"), + contentType: "image/png", + fileName: "avatar.png", + })), + env: {}, + }; + } + + it("persists converted avatar URLs after profile sync", async () => { + const params = createParams(); + const updatedCfg = { channels: { matrix: { avatarUrl: "mxc://avatar" } } }; + hoisted.syncMatrixOwnProfile.mockResolvedValue( + createProfileSyncResult({ + avatarUpdated: true, + resolvedAvatarUrl: "mxc://avatar", + uploadedAvatarSource: "http", + convertedAvatarFromHttp: true, + }), + ); + hoisted.updateMatrixAccountConfig.mockReturnValue(updatedCfg); + + await runMatrixStartupMaintenance(params); + + expect(hoisted.syncMatrixOwnProfile).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "@bot:example.org", + displayName: "Ops Bot", + avatarUrl: "https://example.org/avatar.png", + }), + ); + expect(hoisted.updateMatrixAccountConfig).toHaveBeenCalledWith( + { channels: { matrix: {} } }, + "ops", + { avatarUrl: "mxc://avatar" }, + ); + expect(params.writeConfigFile).toHaveBeenCalledWith(updatedCfg as never); + expect(params.logVerboseMessage).toHaveBeenCalledWith( + "matrix: persisted converted avatar URL for account ops (mxc://avatar)", + ); + }); + + it("reports stale devices, pending verification, and restored legacy backups", async () => { + const params = createParams(); + params.auth.encryption = true; + hoisted.summarizeMatrixDeviceHealth.mockReturnValue({ + staleOpenClawDevices: [{ deviceId: "DEV123" }], + }); + hoisted.ensureMatrixStartupVerification.mockResolvedValue( + createStartupVerificationOutcome("pending"), + ); + hoisted.maybeRestoreLegacyMatrixBackup.mockResolvedValue( + createLegacyCryptoRestoreResult({ + kind: "restored", + imported: 2, + total: 3, + localOnlyKeys: 1, + }), + ); + + await runMatrixStartupMaintenance(params); + + expect(params.logger.warn).toHaveBeenCalledWith( + "matrix: stale OpenClaw devices detected for @bot:example.org: DEV123. Run 'openclaw matrix devices prune-stale --account ops' to keep encrypted-room trust healthy.", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: startup verification request is already pending; finish it in another Matrix client", + ); + expect(params.logger.info).toHaveBeenCalledWith( + "matrix: restored 2/3 room key(s) from legacy encrypted-state backup", + ); + expect(params.logger.warn).toHaveBeenCalledWith( + "matrix: 1 legacy local-only room key(s) were never backed up and could not be restored automatically", + ); + }); + + it("logs cooldown and request-failure verification outcomes without throwing", async () => { + const params = createParams(); + params.auth.encryption = true; + hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce( + createStartupVerificationOutcome("cooldown", { retryAfterMs: 321 }), + ); + + await runMatrixStartupMaintenance(params); + + expect(params.logVerboseMessage).toHaveBeenCalledWith( + "matrix: skipped startup verification request due to cooldown (retryAfterMs=321)", + ); + + hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce( + createStartupVerificationOutcome("request-failed", { error: "boom" }), + ); + + await runMatrixStartupMaintenance(params); + + expect(params.logger.debug).toHaveBeenCalledWith( + "Matrix startup verification request failed (non-fatal)", + { error: "boom" }, + ); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/startup.ts b/extensions/matrix/src/matrix/monitor/startup.ts new file mode 100644 index 00000000000..243afa612dd --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/startup.ts @@ -0,0 +1,160 @@ +import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import type { CoreConfig, MatrixConfig } from "../../types.js"; +import type { MatrixAuth } from "../client.js"; +import { updateMatrixAccountConfig } from "../config-update.js"; +import { summarizeMatrixDeviceHealth } from "../device-health.js"; +import { syncMatrixOwnProfile } from "../profile.js"; +import type { MatrixClient } from "../sdk.js"; +import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; +import { ensureMatrixStartupVerification } from "./startup-verification.js"; + +type MatrixStartupClient = Pick< + MatrixClient, + | "crypto" + | "getOwnDeviceVerificationStatus" + | "getUserProfile" + | "listOwnDevices" + | "restoreRoomKeyBackup" + | "setAvatarUrl" + | "setDisplayName" + | "uploadContent" +>; + +export async function runMatrixStartupMaintenance(params: { + client: MatrixStartupClient; + auth: MatrixAuth; + accountId: string; + effectiveAccountId: string; + accountConfig: MatrixConfig; + logger: RuntimeLogger; + logVerboseMessage: (message: string) => void; + loadConfig: () => CoreConfig; + writeConfigFile: (cfg: never) => Promise; + loadWebMedia: ( + url: string, + maxBytes: number, + ) => Promise<{ buffer: Buffer; contentType?: string; fileName?: string }>; + env?: NodeJS.ProcessEnv; +}): Promise { + try { + const profileSync = await syncMatrixOwnProfile({ + client: params.client, + userId: params.auth.userId, + displayName: params.accountConfig.name, + avatarUrl: params.accountConfig.avatarUrl, + loadAvatarFromUrl: async (url, maxBytes) => await params.loadWebMedia(url, maxBytes), + }); + if (profileSync.displayNameUpdated) { + params.logger.info(`matrix: profile display name updated for ${params.auth.userId}`); + } + if (profileSync.avatarUpdated) { + params.logger.info(`matrix: profile avatar updated for ${params.auth.userId}`); + } + if ( + profileSync.convertedAvatarFromHttp && + profileSync.resolvedAvatarUrl && + params.accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl + ) { + const latestCfg = params.loadConfig(); + const updatedCfg = updateMatrixAccountConfig(latestCfg, params.accountId, { + avatarUrl: profileSync.resolvedAvatarUrl, + }); + await params.writeConfigFile(updatedCfg as never); + params.logVerboseMessage( + `matrix: persisted converted avatar URL for account ${params.accountId} (${profileSync.resolvedAvatarUrl})`, + ); + } + } catch (err) { + params.logger.warn("matrix: failed to sync profile from config", { error: String(err) }); + } + + if (!(params.auth.encryption && params.client.crypto)) { + return; + } + + try { + const deviceHealth = summarizeMatrixDeviceHealth(await params.client.listOwnDevices()); + if (deviceHealth.staleOpenClawDevices.length > 0) { + params.logger.warn( + `matrix: stale OpenClaw devices detected for ${params.auth.userId}: ${deviceHealth.staleOpenClawDevices.map((device) => device.deviceId).join(", ")}. Run 'openclaw matrix devices prune-stale --account ${params.effectiveAccountId}' to keep encrypted-room trust healthy.`, + ); + } + } catch (err) { + params.logger.debug?.("Failed to inspect matrix device hygiene (non-fatal)", { + error: String(err), + }); + } + + try { + const startupVerification = await ensureMatrixStartupVerification({ + client: params.client, + auth: params.auth, + accountConfig: params.accountConfig, + env: params.env, + }); + if (startupVerification.kind === "verified") { + params.logger.info("matrix: device is verified by its owner and ready for encrypted rooms"); + } else if ( + startupVerification.kind === "disabled" || + startupVerification.kind === "cooldown" || + startupVerification.kind === "pending" || + startupVerification.kind === "request-failed" + ) { + params.logger.info( + "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", + ); + if (startupVerification.kind === "pending") { + params.logger.info( + "matrix: startup verification request is already pending; finish it in another Matrix client", + ); + } else if (startupVerification.kind === "cooldown") { + params.logVerboseMessage( + `matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`, + ); + } else if (startupVerification.kind === "request-failed") { + params.logger.debug?.("Matrix startup verification request failed (non-fatal)", { + error: startupVerification.error ?? "unknown", + }); + } + } else if (startupVerification.kind === "requested") { + params.logger.info( + "matrix: device not verified — requested verification in another Matrix client", + ); + } + } catch (err) { + params.logger.debug?.("Failed to resolve matrix verification status (non-fatal)", { + error: String(err), + }); + } + + try { + const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({ + client: params.client, + auth: params.auth, + env: params.env, + }); + if (legacyCryptoRestore.kind === "restored") { + params.logger.info( + `matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + params.logger.warn( + `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`, + ); + } + } else if (legacyCryptoRestore.kind === "failed") { + params.logger.warn( + `matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + params.logger.warn( + `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`, + ); + } + } + } catch (err) { + params.logger.warn("matrix: failed restoring legacy encrypted-state backup", { + error: String(err), + }); + } +} diff --git a/extensions/matrix/src/matrix/monitor/thread-context.test.ts b/extensions/matrix/src/matrix/monitor/thread-context.test.ts new file mode 100644 index 00000000000..2e1dd16c833 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/thread-context.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createMatrixThreadContextResolver, + summarizeMatrixThreadStarterEvent, +} from "./thread-context.js"; +import type { MatrixRawEvent } from "./types.js"; + +describe("matrix thread context", () => { + it("summarizes thread starter events from body text", () => { + expect( + summarizeMatrixThreadStarterEvent({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: " Thread starter body ", + }, + } as MatrixRawEvent), + ).toBe("Thread starter body"); + }); + + it("marks media-only thread starter events instead of returning bare filenames", () => { + expect( + summarizeMatrixThreadStarterEvent({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: Date.now(), + content: { + msgtype: "m.image", + body: "photo.jpg", + }, + } as MatrixRawEvent), + ).toBe("[matrix image attachment]"); + }); + + it("resolves and caches thread starter context", async () => { + const getEvent = vi.fn(async () => ({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "Root topic", + }, + })); + const getMemberDisplayName = vi.fn(async () => "Alice"); + const resolveThreadContext = createMatrixThreadContextResolver({ + client: { + getEvent, + } as never, + getMemberDisplayName, + logVerboseMessage: () => {}, + }); + + await expect( + resolveThreadContext({ + roomId: "!room:example.org", + threadRootId: "$root", + }), + ).resolves.toEqual({ + threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic", + }); + + await resolveThreadContext({ + roomId: "!room:example.org", + threadRootId: "$root", + }); + + expect(getEvent).toHaveBeenCalledTimes(1); + expect(getMemberDisplayName).toHaveBeenCalledTimes(1); + }); + + it("does not cache thread starter fetch failures", async () => { + const getEvent = vi + .fn() + .mockRejectedValueOnce(new Error("temporary failure")) + .mockResolvedValueOnce({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "Recovered topic", + }, + }); + const getMemberDisplayName = vi.fn(async () => "Alice"); + const resolveThreadContext = createMatrixThreadContextResolver({ + client: { + getEvent, + } as never, + getMemberDisplayName, + logVerboseMessage: () => {}, + }); + + await expect( + resolveThreadContext({ + roomId: "!room:example.org", + threadRootId: "$root", + }), + ).resolves.toEqual({ + threadStarterBody: "Matrix thread root $root", + }); + + await expect( + resolveThreadContext({ + roomId: "!room:example.org", + threadRootId: "$root", + }), + ).resolves.toEqual({ + threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic", + }); + + expect(getEvent).toHaveBeenCalledTimes(2); + expect(getMemberDisplayName).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/thread-context.ts b/extensions/matrix/src/matrix/monitor/thread-context.ts new file mode 100644 index 00000000000..9a9fc3a29cc --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/thread-context.ts @@ -0,0 +1,123 @@ +import { + formatMatrixMessageText, + resolveMatrixMessageAttachment, + resolveMatrixMessageBody, +} from "../media-text.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; + +const MAX_TRACKED_THREAD_STARTERS = 256; +const MAX_THREAD_STARTER_BODY_LENGTH = 500; + +type MatrixThreadContext = { + threadStarterBody?: string; +}; + +function trimMaybeString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function truncateThreadStarterBody(value: string): string { + if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) { + return value; + } + return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`; +} + +export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined { + const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown }; + const body = formatMatrixMessageText({ + body: resolveMatrixMessageBody({ + body: trimMaybeString(content.body), + filename: trimMaybeString(content.filename), + msgtype: trimMaybeString(content.msgtype), + }), + attachment: resolveMatrixMessageAttachment({ + body: trimMaybeString(content.body), + filename: trimMaybeString(content.filename), + msgtype: trimMaybeString(content.msgtype), + }), + }); + if (body) { + return truncateThreadStarterBody(body); + } + const msgtype = trimMaybeString(content.msgtype); + if (msgtype) { + return `Matrix ${msgtype} message`; + } + const eventType = trimMaybeString(event.type); + return eventType ? `Matrix ${eventType} event` : undefined; +} + +function formatMatrixThreadStarterBody(params: { + threadRootId: string; + senderName?: string; + senderId?: string; + summary?: string; +}): string { + const senderLabel = params.senderName ?? params.senderId ?? "unknown sender"; + const lines = [`Matrix thread root ${params.threadRootId} from ${senderLabel}:`]; + if (params.summary) { + lines.push(params.summary); + } + return lines.join("\n"); +} + +export function createMatrixThreadContextResolver(params: { + client: MatrixClient; + getMemberDisplayName: (roomId: string, userId: string) => Promise; + logVerboseMessage: (message: string) => void; +}) { + const cache = new Map(); + + const remember = (key: string, value: MatrixThreadContext): MatrixThreadContext => { + cache.set(key, value); + if (cache.size > MAX_TRACKED_THREAD_STARTERS) { + const oldest = cache.keys().next().value; + if (typeof oldest === "string") { + cache.delete(oldest); + } + } + return value; + }; + + return async (input: { roomId: string; threadRootId: string }): Promise => { + const cacheKey = `${input.roomId}:${input.threadRootId}`; + const cached = cache.get(cacheKey); + if (cached) { + return cached; + } + + const rootEvent = await params.client + .getEvent(input.roomId, input.threadRootId) + .catch((err) => { + params.logVerboseMessage( + `matrix: failed resolving thread root room=${input.roomId} id=${input.threadRootId}: ${String(err)}`, + ); + return null; + }); + if (!rootEvent) { + return { + threadStarterBody: `Matrix thread root ${input.threadRootId}`, + }; + } + + const rawEvent = rootEvent as MatrixRawEvent; + const senderId = trimMaybeString(rawEvent.sender); + const senderName = + senderId && + (await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined)); + return remember(cacheKey, { + threadStarterBody: formatMatrixThreadStarterBody({ + threadRootId: input.threadRootId, + senderId, + senderName, + summary: summarizeMatrixThreadStarterEvent(rawEvent), + }), + }); + }; +} diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts index a384957166b..3c90e08dbfd 100644 --- a/extensions/matrix/src/matrix/monitor/threads.ts +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -1,25 +1,5 @@ -// Type for raw Matrix event from @vector-im/matrix-bot-sdk -type MatrixRawEvent = { - event_id: string; - sender: string; - type: string; - origin_server_ts: number; - content: Record; -}; - -type RoomMessageEventContent = { - msgtype: string; - body: string; - "m.relates_to"?: { - rel_type?: string; - event_id?: string; - "m.in_reply_to"?: { event_id?: string }; - }; -}; - -const RelationType = { - Thread: "m.thread", -} as const; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; +import { RelationType } from "./types.js"; export function resolveMatrixThreadTarget(params: { threadReplies: "off" | "inbound" | "always"; diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index c910f931fa9..83552931906 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -1,10 +1,13 @@ -import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk"; +import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js"; +import type { EncryptedFile, MessageEventContent } from "../sdk.js"; +export type { MatrixRawEvent } from "../sdk.js"; export const EventType = { RoomMessage: "m.room.message", RoomMessageEncrypted: "m.room.encrypted", RoomMember: "m.room.member", Location: "m.location", + Reaction: MATRIX_REACTION_EVENT_TYPE, } as const; export const RelationType = { @@ -12,18 +15,6 @@ export const RelationType = { Thread: "m.thread", } as const; -export type MatrixRawEvent = { - event_id: string; - sender: string; - type: string; - origin_server_ts: number; - content: Record; - unsigned?: { - age?: number; - redacted_because?: unknown; - }; -}; - export type RoomMessageEventContent = MessageEventContent & { url?: string; file?: EncryptedFile; diff --git a/extensions/matrix/src/matrix/monitor/verification-events.ts b/extensions/matrix/src/matrix/monitor/verification-events.ts new file mode 100644 index 00000000000..2fb770dabce --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/verification-events.ts @@ -0,0 +1,512 @@ +import { inspectMatrixDirectRooms } from "../direct-management.js"; +import { isStrictDirectRoom } from "../direct-room.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; +import { + isMatrixVerificationEventType, + isMatrixVerificationRequestMsgType, + matrixVerificationConstants, +} from "./verification-utils.js"; + +const MAX_TRACKED_VERIFICATION_EVENTS = 1024; +const SAS_NOTICE_RETRY_DELAY_MS = 750; +const VERIFICATION_EVENT_STARTUP_GRACE_MS = 30_000; + +type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; + +type MatrixVerificationSummaryLike = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + updatedAt?: string; + completed?: boolean; + pending?: boolean; + phase?: number; + phaseName?: string; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; +}; + +function trimMaybeString(input: unknown): string | null { + if (typeof input !== "string") { + return null; + } + const trimmed = input.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readVerificationSignal(event: MatrixRawEvent): { + stage: MatrixVerificationStage; + flowId: string | null; +} | null { + const type = trimMaybeString(event?.type) ?? ""; + const content = event?.content ?? {}; + const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; + const relatedEventId = trimMaybeString( + (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, + ); + const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); + if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { + return { + stage: "request", + flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, + }; + } + if (!isMatrixVerificationEventType(type)) { + return null; + } + const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); + if (type === `${matrixVerificationConstants.eventPrefix}request`) { + return { stage: "request", flowId }; + } + if (type === `${matrixVerificationConstants.eventPrefix}ready`) { + return { stage: "ready", flowId }; + } + if (type === "m.key.verification.start") { + return { stage: "start", flowId }; + } + if (type === "m.key.verification.cancel") { + return { stage: "cancel", flowId }; + } + if (type === "m.key.verification.done") { + return { stage: "done", flowId }; + } + return { stage: "other", flowId }; +} + +function formatVerificationStageNotice(params: { + stage: MatrixVerificationStage; + senderId: string; + event: MatrixRawEvent; +}): string | null { + const { stage, senderId, event } = params; + const content = event.content as { code?: unknown; reason?: unknown }; + switch (stage) { + case "request": + return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; + case "ready": + return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; + case "start": + return `Matrix verification started with ${senderId}.`; + case "done": + return `Matrix verification completed with ${senderId}.`; + case "cancel": { + const code = trimMaybeString(content.code); + const reason = trimMaybeString(content.reason); + if (code && reason) { + return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`; + } + if (reason) { + return `Matrix verification cancelled by ${senderId} (${reason}).`; + } + return `Matrix verification cancelled by ${senderId}.`; + } + default: + return null; + } +} + +function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null { + const sas = summary.sas; + if (!sas) { + return null; + } + const emojiLine = + Array.isArray(sas.emoji) && sas.emoji.length > 0 + ? `SAS emoji: ${sas.emoji + .map( + ([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`, + ) + .join(" | ")}` + : null; + const decimalLine = + Array.isArray(sas.decimal) && sas.decimal.length === 3 + ? `SAS decimal: ${sas.decimal.join(" ")}` + : null; + if (!emojiLine && !decimalLine) { + return null; + } + const lines = [`Matrix verification SAS with ${summary.otherUserId}:`]; + if (emojiLine) { + lines.push(emojiLine); + } + if (decimalLine) { + lines.push(decimalLine); + } + lines.push("If both sides match, choose 'They match' in your Matrix app."); + return lines.join("\n"); +} + +function resolveVerificationFlowCandidates(params: { + event: MatrixRawEvent; + flowId: string | null; +}): string[] { + const { event, flowId } = params; + const content = event.content as { + transaction_id?: unknown; + "m.relates_to"?: { event_id?: unknown }; + }; + const candidates = new Set(); + const add = (value: unknown) => { + const normalized = trimMaybeString(value); + if (normalized) { + candidates.add(normalized); + } + }; + add(flowId); + add(event.event_id); + add(content.transaction_id); + add(content["m.relates_to"]?.event_id); + return Array.from(candidates); +} + +function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { + const ts = Date.parse(summary.updatedAt ?? ""); + return Number.isFinite(ts) ? ts : 0; +} + +function isActiveVerificationSummary(summary: MatrixVerificationSummaryLike): boolean { + if (summary.completed === true) { + return false; + } + if (summary.phaseName === "cancelled" || summary.phaseName === "done") { + return false; + } + if (typeof summary.phase === "number" && summary.phase >= 4) { + return false; + } + return true; +} + +async function resolveVerificationSummaryForSignal( + client: MatrixClient, + params: { + roomId: string; + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + }, +): Promise { + if (!client.crypto) { + return null; + } + await client.crypto + .ensureVerificationDmTracked({ + roomId: params.roomId, + userId: params.senderId, + }) + .catch(() => null); + const list = await client.crypto.listVerifications(); + if (list.length === 0) { + return null; + } + const candidates = resolveVerificationFlowCandidates({ + event: params.event, + flowId: params.flowId, + }); + const byTransactionId = list.find((entry) => + candidates.some((candidate) => entry.transactionId === candidate), + ); + if (byTransactionId) { + return byTransactionId; + } + + // Only fall back by user inside the active DM with that user. Otherwise a + // spoofed verification event in an unrelated room can leak the current SAS + // prompt into that room. + if ( + !(await isStrictDirectRoom({ + client, + roomId: params.roomId, + remoteUserId: params.senderId, + })) + ) { + return null; + } + + // Fallback for DM flows where transaction IDs do not match room event IDs consistently. + const activeByUser = list + .filter((entry) => entry.otherUserId === params.senderId && isActiveVerificationSummary(entry)) + .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a)); + const activeInRoom = activeByUser.filter((entry) => { + const roomId = trimMaybeString(entry.roomId); + return roomId === params.roomId; + }); + if (activeInRoom.length > 0) { + return activeInRoom[0] ?? null; + } + return activeByUser[0] ?? null; +} + +async function resolveVerificationSasNoticeForSignal( + client: MatrixClient, + params: { + roomId: string; + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + stage: MatrixVerificationStage; + }, +): Promise<{ summary: MatrixVerificationSummaryLike | null; sasNotice: string | null }> { + const summary = await resolveVerificationSummaryForSignal(client, params); + const immediateNotice = + summary && isActiveVerificationSummary(summary) ? formatVerificationSasNotice(summary) : null; + if (immediateNotice || (params.stage !== "ready" && params.stage !== "start")) { + return { + summary, + sasNotice: immediateNotice, + }; + } + + await new Promise((resolve) => setTimeout(resolve, SAS_NOTICE_RETRY_DELAY_MS)); + const retriedSummary = await resolveVerificationSummaryForSignal(client, params); + return { + summary: retriedSummary, + sasNotice: + retriedSummary && isActiveVerificationSummary(retriedSummary) + ? formatVerificationSasNotice(retriedSummary) + : null, + }; +} + +function trackBounded(set: Set, value: string): boolean { + if (!value || set.has(value)) { + return false; + } + set.add(value); + if (set.size > MAX_TRACKED_VERIFICATION_EVENTS) { + const oldest = set.values().next().value; + if (typeof oldest === "string") { + set.delete(oldest); + } + } + return true; +} + +async function sendVerificationNotice(params: { + client: MatrixClient; + roomId: string; + body: string; + logVerboseMessage: (message: string) => void; +}): Promise { + const roomId = trimMaybeString(params.roomId); + if (!roomId) { + return; + } + try { + await params.client.sendMessage(roomId, { + msgtype: "m.notice", + body: params.body, + }); + } catch (err) { + params.logVerboseMessage( + `matrix: failed sending verification notice room=${roomId}: ${String(err)}`, + ); + } +} + +export function createMatrixVerificationEventRouter(params: { + client: MatrixClient; + logVerboseMessage: (message: string) => void; +}) { + const routerStartedAtMs = Date.now(); + const routedVerificationEvents = new Set(); + const routedVerificationSasFingerprints = new Set(); + const routedVerificationStageNotices = new Set(); + const verificationFlowRooms = new Map(); + const verificationUserRooms = new Map(); + + function shouldEmitVerificationEventNotice(event: MatrixRawEvent): boolean { + const eventTs = + typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts) + ? event.origin_server_ts + : null; + if (eventTs === null) { + return true; + } + return eventTs >= routerStartedAtMs - VERIFICATION_EVENT_STARTUP_GRACE_MS; + } + + function rememberVerificationRoom(roomId: string, event: MatrixRawEvent, flowId: string | null) { + for (const candidate of resolveVerificationFlowCandidates({ event, flowId })) { + verificationFlowRooms.set(candidate, roomId); + if (verificationFlowRooms.size > MAX_TRACKED_VERIFICATION_EVENTS) { + const oldest = verificationFlowRooms.keys().next().value; + if (typeof oldest === "string") { + verificationFlowRooms.delete(oldest); + } + } + } + } + + function rememberVerificationUserRoom(remoteUserId: string, roomId: string): void { + const normalizedUserId = trimMaybeString(remoteUserId); + const normalizedRoomId = trimMaybeString(roomId); + if (!normalizedUserId || !normalizedRoomId) { + return; + } + verificationUserRooms.delete(normalizedUserId); + verificationUserRooms.set(normalizedUserId, normalizedRoomId); + if (verificationUserRooms.size > MAX_TRACKED_VERIFICATION_EVENTS) { + const oldest = verificationUserRooms.keys().next().value; + if (typeof oldest === "string") { + verificationUserRooms.delete(oldest); + } + } + } + + async function resolveSummaryRoomId( + summary: MatrixVerificationSummaryLike, + ): Promise { + const mappedRoomId = + trimMaybeString(summary.roomId) ?? + trimMaybeString( + summary.transactionId ? verificationFlowRooms.get(summary.transactionId) : null, + ) ?? + trimMaybeString(verificationFlowRooms.get(summary.id)); + if (mappedRoomId) { + return mappedRoomId; + } + + const remoteUserId = trimMaybeString(summary.otherUserId); + if (!remoteUserId) { + return null; + } + const recentRoomId = trimMaybeString(verificationUserRooms.get(remoteUserId)); + if ( + recentRoomId && + (await isStrictDirectRoom({ + client: params.client, + roomId: recentRoomId, + remoteUserId, + })) + ) { + return recentRoomId; + } + const inspection = await inspectMatrixDirectRooms({ + client: params.client, + remoteUserId, + }).catch(() => null); + return trimMaybeString(inspection?.activeRoomId); + } + + async function routeVerificationSummary(summary: MatrixVerificationSummaryLike): Promise { + const roomId = await resolveSummaryRoomId(summary); + if (!roomId || !isActiveVerificationSummary(summary)) { + return; + } + if ( + !(await isStrictDirectRoom({ + client: params.client, + roomId, + remoteUserId: summary.otherUserId, + })) + ) { + params.logVerboseMessage( + `matrix: ignoring verification summary outside strict DM room=${roomId} sender=${summary.otherUserId}`, + ); + return; + } + const sasNotice = formatVerificationSasNotice(summary); + if (!sasNotice) { + return; + } + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (!trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + return; + } + await sendVerificationNotice({ + client: params.client, + roomId, + body: sasNotice, + logVerboseMessage: params.logVerboseMessage, + }); + } + + function routeVerificationEvent(roomId: string, event: MatrixRawEvent): boolean { + const senderId = trimMaybeString(event?.sender); + if (!senderId) { + return false; + } + const signal = readVerificationSignal(event); + if (!signal) { + return false; + } + rememberVerificationRoom(roomId, event, signal.flowId); + + void (async () => { + if (!shouldEmitVerificationEventNotice(event)) { + params.logVerboseMessage( + `matrix: ignoring historical verification event room=${roomId} id=${event.event_id ?? "unknown"} type=${event.type ?? "unknown"}`, + ); + return; + } + const flowId = signal.flowId; + const sourceEventId = trimMaybeString(event?.event_id); + const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; + const shouldRouteInRoom = await isStrictDirectRoom({ + client: params.client, + roomId, + remoteUserId: senderId, + }); + if (!shouldRouteInRoom) { + params.logVerboseMessage( + `matrix: ignoring verification event outside strict DM room=${roomId} sender=${senderId}`, + ); + return; + } + rememberVerificationUserRoom(senderId, roomId); + if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { + return; + } + + const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); + const { summary, sasNotice } = await resolveVerificationSasNoticeForSignal(params.client, { + roomId, + event, + senderId, + flowId, + stage: signal.stage, + }).catch(() => ({ summary: null, sasNotice: null })); + + const notices: string[] = []; + if (stageNotice) { + const stageKey = `${roomId}:${senderId}:${flowId ?? sourceFingerprint}:${signal.stage}`; + if (trackBounded(routedVerificationStageNotices, stageKey)) { + notices.push(stageNotice); + } + } + if (summary && sasNotice) { + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + notices.push(sasNotice); + } + } + if (notices.length === 0) { + return; + } + + for (const body of notices) { + await sendVerificationNotice({ + client: params.client, + roomId, + body, + logVerboseMessage: params.logVerboseMessage, + }); + } + })().catch((err) => { + params.logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); + }); + + return true; + } + + return { + routeVerificationEvent, + routeVerificationSummary, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/verification-utils.test.ts b/extensions/matrix/src/matrix/monitor/verification-utils.test.ts new file mode 100644 index 00000000000..5093e73939d --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/verification-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + isMatrixVerificationEventType, + isMatrixVerificationNoticeBody, + isMatrixVerificationRequestMsgType, + isMatrixVerificationRoomMessage, +} from "./verification-utils.js"; + +describe("matrix verification message classifiers", () => { + it("recognizes verification event types", () => { + expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true); + expect(isMatrixVerificationEventType("m.room.message")).toBe(false); + }); + + it("recognizes verification request message type", () => { + expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true); + expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false); + }); + + it("recognizes verification notice bodies", () => { + expect( + isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."), + ).toBe(true); + expect(isMatrixVerificationNoticeBody("hello world")).toBe(false); + }); + + it("classifies verification room messages", () => { + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.key.verification.request", + body: "verify request", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.notice", + body: "Matrix verification cancelled by @alice:example.org.", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.text", + body: "normal chat message", + }), + ).toBe(false); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/verification-utils.ts b/extensions/matrix/src/matrix/monitor/verification-utils.ts new file mode 100644 index 00000000000..d777167c4ff --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/verification-utils.ts @@ -0,0 +1,44 @@ +const VERIFICATION_EVENT_PREFIX = "m.key.verification."; +const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request"; + +const VERIFICATION_NOTICE_PREFIXES = [ + "Matrix verification request received from ", + "Matrix verification is ready with ", + "Matrix verification started with ", + "Matrix verification completed with ", + "Matrix verification cancelled by ", + "Matrix verification SAS with ", +]; + +function trimMaybeString(input: unknown): string { + return typeof input === "string" ? input.trim() : ""; +} + +export function isMatrixVerificationEventType(type: unknown): boolean { + return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX); +} + +export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean { + return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE; +} + +export function isMatrixVerificationNoticeBody(body: unknown): boolean { + const text = trimMaybeString(body); + return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix)); +} + +export function isMatrixVerificationRoomMessage(content: { + msgtype?: unknown; + body?: unknown; +}): boolean { + return ( + isMatrixVerificationRequestMsgType(content.msgtype) || + (trimMaybeString(content.msgtype) === "m.notice" && + isMatrixVerificationNoticeBody(content.body)) + ); +} + +export const matrixVerificationConstants = { + eventPrefix: VERIFICATION_EVENT_PREFIX, + requestMsgtype: VERIFICATION_REQUEST_MSGTYPE, +} as const; diff --git a/extensions/matrix/src/matrix/poll-summary.ts b/extensions/matrix/src/matrix/poll-summary.ts new file mode 100644 index 00000000000..f98723826ce --- /dev/null +++ b/extensions/matrix/src/matrix/poll-summary.ts @@ -0,0 +1,110 @@ +import type { MatrixMessageSummary } from "./actions/types.js"; +import { + buildPollResultsSummary, + formatPollAsText, + formatPollResultsAsText, + isPollEventType, + isPollStartType, + parsePollStartContent, + resolvePollReferenceEventId, + type PollStartContent, +} from "./poll-types.js"; +import type { MatrixClient, MatrixRawEvent } from "./sdk.js"; + +export type MatrixPollSnapshot = { + pollEventId: string; + triggerEvent: MatrixRawEvent; + rootEvent: MatrixRawEvent; + text: string; +}; + +export function resolveMatrixPollRootEventId( + event: Pick, +): string | null { + if (isPollStartType(event.type)) { + const eventId = event.event_id?.trim(); + return eventId ? eventId : null; + } + return resolvePollReferenceEventId(event.content); +} + +async function readAllPollRelations( + client: MatrixClient, + roomId: string, + pollEventId: string, +): Promise { + const relationEvents: MatrixRawEvent[] = []; + let nextBatch: string | undefined; + do { + const page = await client.getRelations(roomId, pollEventId, "m.reference", undefined, { + from: nextBatch, + }); + relationEvents.push(...page.events); + nextBatch = page.nextBatch ?? undefined; + } while (nextBatch); + return relationEvents; +} + +export async function fetchMatrixPollSnapshot( + client: MatrixClient, + roomId: string, + event: MatrixRawEvent, +): Promise { + if (!isPollEventType(event.type)) { + return null; + } + + const pollEventId = resolveMatrixPollRootEventId(event); + if (!pollEventId) { + return null; + } + + const rootEvent = isPollStartType(event.type) + ? event + : ((await client.getEvent(roomId, pollEventId)) as MatrixRawEvent); + if (!isPollStartType(rootEvent.type)) { + return null; + } + + const pollStartContent = rootEvent.content as PollStartContent; + const pollSummary = parsePollStartContent(pollStartContent); + if (!pollSummary) { + return null; + } + + const relationEvents = await readAllPollRelations(client, roomId, pollEventId); + const pollResults = buildPollResultsSummary({ + pollEventId, + roomId, + sender: rootEvent.sender, + senderName: rootEvent.sender, + content: pollStartContent, + relationEvents, + }); + + return { + pollEventId, + triggerEvent: event, + rootEvent, + text: pollResults ? formatPollResultsAsText(pollResults) : formatPollAsText(pollSummary), + }; +} + +export async function fetchMatrixPollMessageSummary( + client: MatrixClient, + roomId: string, + event: MatrixRawEvent, +): Promise { + const snapshot = await fetchMatrixPollSnapshot(client, roomId, event); + if (!snapshot) { + return null; + } + + return { + eventId: snapshot.pollEventId, + sender: snapshot.rootEvent.sender, + body: snapshot.text, + msgtype: "m.text", + timestamp: snapshot.triggerEvent.origin_server_ts || snapshot.rootEvent.origin_server_ts, + }; +} diff --git a/extensions/matrix/src/matrix/poll-types.test.ts b/extensions/matrix/src/matrix/poll-types.test.ts index 7f1797d99c6..9e129a45664 100644 --- a/extensions/matrix/src/matrix/poll-types.test.ts +++ b/extensions/matrix/src/matrix/poll-types.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { parsePollStartContent } from "./poll-types.js"; +import { + buildPollResultsSummary, + buildPollResponseContent, + buildPollStartContent, + formatPollResultsAsText, + parsePollStart, + parsePollResponseAnswerIds, + parsePollStartContent, + resolvePollReferenceEventId, +} from "./poll-types.js"; describe("parsePollStartContent", () => { it("parses legacy m.poll payloads", () => { @@ -18,4 +27,179 @@ describe("parsePollStartContent", () => { expect(summary?.question).toBe("Lunch?"); expect(summary?.answers).toEqual(["Yes", "No"]); }); + + it("preserves answer ids when parsing poll start content", () => { + const parsed = parsePollStart({ + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Yes" }, + { id: "a2", "m.text": "No" }, + ], + }, + }); + + expect(parsed).toMatchObject({ + question: "Lunch?", + answers: [ + { id: "a1", text: "Yes" }, + { id: "a2", text: "No" }, + ], + maxSelections: 1, + }); + }); + + it("caps invalid remote max selections to the available answer count", () => { + const parsed = parsePollStart({ + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.undisclosed", + max_selections: 99, + answers: [ + { id: "a1", "m.text": "Yes" }, + { id: "a2", "m.text": "No" }, + ], + }, + }); + + expect(parsed?.maxSelections).toBe(2); + }); +}); + +describe("buildPollStartContent", () => { + it("preserves the requested multiselect cap instead of widening to all answers", () => { + const content = buildPollStartContent({ + question: "Lunch?", + options: ["Pizza", "Sushi", "Tacos"], + maxSelections: 2, + }); + + expect(content["m.poll.start"]?.max_selections).toBe(2); + expect(content["m.poll.start"]?.kind).toBe("m.poll.undisclosed"); + }); +}); + +describe("buildPollResponseContent", () => { + it("builds a poll response payload with a reference relation", () => { + expect(buildPollResponseContent("$poll", ["a2"])).toEqual({ + "m.poll.response": { + answers: ["a2"], + }, + "org.matrix.msc3381.poll.response": { + answers: ["a2"], + }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + }); +}); + +describe("poll relation parsing", () => { + it("parses stable and unstable poll response answer ids", () => { + expect( + parsePollResponseAnswerIds({ + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }), + ).toEqual(["a1"]); + expect( + parsePollResponseAnswerIds({ + "org.matrix.msc3381.poll.response": { answers: ["a2"] }, + }), + ).toEqual(["a2"]); + }); + + it("extracts poll relation targets", () => { + expect( + resolvePollReferenceEventId({ + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }), + ).toBe("$poll"); + }); +}); + +describe("buildPollResultsSummary", () => { + it("counts only the latest valid response from each sender", () => { + const summary = buildPollResultsSummary({ + pollEventId: "$poll", + roomId: "!room:example.org", + sender: "@alice:example.org", + senderName: "Alice", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + relationEvents: [ + { + event_id: "$vote1", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 1, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$vote2", + sender: "@bob:example.org", + type: "m.poll.response", + origin_server_ts: 2, + content: { + "m.poll.response": { answers: ["a2"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + { + event_id: "$vote3", + sender: "@carol:example.org", + type: "m.poll.response", + origin_server_ts: 3, + content: { + "m.poll.response": { answers: [] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }, + ], + }); + + expect(summary?.entries).toEqual([ + { id: "a1", text: "Pizza", votes: 0 }, + { id: "a2", text: "Sushi", votes: 1 }, + ]); + expect(summary?.totalVotes).toBe(1); + }); + + it("formats disclosed poll results with vote totals", () => { + const text = formatPollResultsAsText({ + eventId: "$poll", + roomId: "!room:example.org", + sender: "@alice:example.org", + senderName: "Alice", + question: "Lunch?", + answers: ["Pizza", "Sushi"], + kind: "m.poll.disclosed", + maxSelections: 1, + entries: [ + { id: "a1", text: "Pizza", votes: 1 }, + { id: "a2", text: "Sushi", votes: 0 }, + ], + totalVotes: 1, + closed: false, + }); + + expect(text).toContain("1. Pizza (1 vote)"); + expect(text).toContain("Total voters: 1"); + }); }); diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index bae8905c4e7..23743df64ee 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -7,7 +7,7 @@ * - m.poll.end - Closes a poll */ -import type { PollInput } from "../../runtime-api.js"; +import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/matrix"; export const M_POLL_START = "m.poll.start" as const; export const M_POLL_RESPONSE = "m.poll.response" as const; @@ -42,6 +42,11 @@ export type PollAnswer = { id: string; } & TextContent; +export type PollParsedAnswer = { + id: string; + text: string; +}; + export type PollStartSubtype = { question: TextContent; kind?: PollKind; @@ -72,10 +77,52 @@ export type PollSummary = { maxSelections: number; }; +export type PollResultsSummary = PollSummary & { + entries: Array<{ + id: string; + text: string; + votes: number; + }>; + totalVotes: number; + closed: boolean; +}; + +export type ParsedPollStart = { + question: string; + answers: PollParsedAnswer[]; + kind: PollKind; + maxSelections: number; +}; + +export type PollResponseSubtype = { + answers: string[]; +}; + +export type PollResponseContent = { + [M_POLL_RESPONSE]?: PollResponseSubtype; + [ORG_POLL_RESPONSE]?: PollResponseSubtype; + "m.relates_to": { + rel_type: "m.reference"; + event_id: string; + }; +}; + export function isPollStartType(eventType: string): boolean { return (POLL_START_TYPES as readonly string[]).includes(eventType); } +export function isPollResponseType(eventType: string): boolean { + return (POLL_RESPONSE_TYPES as readonly string[]).includes(eventType); +} + +export function isPollEndType(eventType: string): boolean { + return (POLL_END_TYPES as readonly string[]).includes(eventType); +} + +export function isPollEventType(eventType: string): boolean { + return (POLL_EVENT_TYPES as readonly string[]).includes(eventType); +} + export function getTextContent(text?: TextContent): string { if (!text) { return ""; @@ -83,7 +130,7 @@ export function getTextContent(text?: TextContent): string { return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; } -export function parsePollStartContent(content: PollStartContent): PollSummary | null { +export function parsePollStart(content: PollStartContent): ParsedPollStart | null { const poll = (content as Record)[M_POLL_START] ?? (content as Record)[ORG_POLL_START] ?? @@ -92,24 +139,50 @@ export function parsePollStartContent(content: PollStartContent): PollSummary | return null; } - const question = getTextContent(poll.question); + const question = getTextContent(poll.question).trim(); if (!question) { return null; } const answers = poll.answers - .map((answer) => getTextContent(answer)) - .filter((a) => a.trim().length > 0); + .map((answer) => ({ + id: answer.id, + text: getTextContent(answer).trim(), + })) + .filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0); + if (answers.length === 0) { + return null; + } + + const maxSelectionsRaw = poll.max_selections; + const maxSelections = + typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) + ? Math.floor(maxSelectionsRaw) + : 1; + + return { + question, + answers, + kind: poll.kind ?? "m.poll.disclosed", + maxSelections: Math.min(Math.max(maxSelections, 1), answers.length), + }; +} + +export function parsePollStartContent(content: PollStartContent): PollSummary | null { + const parsed = parsePollStart(content); + if (!parsed) { + return null; + } return { eventId: "", roomId: "", sender: "", senderName: "", - question, - answers, - kind: poll.kind ?? "m.poll.disclosed", - maxSelections: poll.max_selections ?? 1, + question: parsed.question, + answers: parsed.answers.map((answer) => answer.text), + kind: parsed.kind, + maxSelections: parsed.maxSelections, }; } @@ -123,6 +196,184 @@ export function formatPollAsText(summary: PollSummary): string { return lines.join("\n"); } +export function resolvePollReferenceEventId(content: unknown): string | null { + if (!content || typeof content !== "object") { + return null; + } + const relates = (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]; + if (!relates || typeof relates.event_id !== "string") { + return null; + } + const eventId = relates.event_id.trim(); + return eventId.length > 0 ? eventId : null; +} + +export function parsePollResponseAnswerIds(content: unknown): string[] | null { + if (!content || typeof content !== "object") { + return null; + } + const response = + (content as Record)[M_POLL_RESPONSE] ?? + (content as Record)[ORG_POLL_RESPONSE]; + if (!response || !Array.isArray(response.answers)) { + return null; + } + return response.answers.filter((answer): answer is string => typeof answer === "string"); +} + +export function buildPollResultsSummary(params: { + pollEventId: string; + roomId: string; + sender: string; + senderName: string; + content: PollStartContent; + relationEvents: Array<{ + event_id?: string; + sender?: string; + type?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: { + redacted_because?: unknown; + }; + }>; +}): PollResultsSummary | null { + const parsed = parsePollStart(params.content); + if (!parsed) { + return null; + } + + let pollClosedAt = Number.POSITIVE_INFINITY; + for (const event of params.relationEvents) { + if (event.unsigned?.redacted_because) { + continue; + } + if (!isPollEndType(typeof event.type === "string" ? event.type : "")) { + continue; + } + if (event.sender !== params.sender) { + continue; + } + const ts = + typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts) + ? event.origin_server_ts + : Number.POSITIVE_INFINITY; + if (ts < pollClosedAt) { + pollClosedAt = ts; + } + } + + const answerIds = new Set(parsed.answers.map((answer) => answer.id)); + const latestVoteBySender = new Map< + string, + { + ts: number; + eventId: string; + answerIds: string[]; + } + >(); + + const orderedRelationEvents = [...params.relationEvents].sort((left, right) => { + const leftTs = + typeof left.origin_server_ts === "number" && Number.isFinite(left.origin_server_ts) + ? left.origin_server_ts + : Number.POSITIVE_INFINITY; + const rightTs = + typeof right.origin_server_ts === "number" && Number.isFinite(right.origin_server_ts) + ? right.origin_server_ts + : Number.POSITIVE_INFINITY; + if (leftTs !== rightTs) { + return leftTs - rightTs; + } + return (left.event_id ?? "").localeCompare(right.event_id ?? ""); + }); + + for (const event of orderedRelationEvents) { + if (event.unsigned?.redacted_because) { + continue; + } + if (!isPollResponseType(typeof event.type === "string" ? event.type : "")) { + continue; + } + const senderId = typeof event.sender === "string" ? event.sender.trim() : ""; + if (!senderId) { + continue; + } + const eventTs = + typeof event.origin_server_ts === "number" && Number.isFinite(event.origin_server_ts) + ? event.origin_server_ts + : Number.POSITIVE_INFINITY; + if (eventTs > pollClosedAt) { + continue; + } + const rawAnswers = parsePollResponseAnswerIds(event.content) ?? []; + const normalizedAnswers = Array.from( + new Set( + rawAnswers + .map((answerId) => answerId.trim()) + .filter((answerId) => answerIds.has(answerId)) + .slice(0, parsed.maxSelections), + ), + ); + latestVoteBySender.set(senderId, { + ts: eventTs, + eventId: typeof event.event_id === "string" ? event.event_id : "", + answerIds: normalizedAnswers, + }); + } + + const voteCounts = new Map( + parsed.answers.map((answer): [string, number] => [answer.id, 0]), + ); + let totalVotes = 0; + for (const latestVote of latestVoteBySender.values()) { + if (latestVote.answerIds.length === 0) { + continue; + } + totalVotes += 1; + for (const answerId of latestVote.answerIds) { + voteCounts.set(answerId, (voteCounts.get(answerId) ?? 0) + 1); + } + } + + return { + eventId: params.pollEventId, + roomId: params.roomId, + sender: params.sender, + senderName: params.senderName, + question: parsed.question, + answers: parsed.answers.map((answer) => answer.text), + kind: parsed.kind, + maxSelections: parsed.maxSelections, + entries: parsed.answers.map((answer) => ({ + id: answer.id, + text: answer.text, + votes: voteCounts.get(answer.id) ?? 0, + })), + totalVotes, + closed: Number.isFinite(pollClosedAt), + }; +} + +export function formatPollResultsAsText(summary: PollResultsSummary): string { + const lines = [summary.closed ? "[Poll closed]" : "[Poll]", summary.question, ""]; + const revealResults = summary.kind === "m.poll.disclosed" || summary.closed; + for (const [index, entry] of summary.entries.entries()) { + if (!revealResults) { + lines.push(`${index + 1}. ${entry.text}`); + continue; + } + lines.push(`${index + 1}. ${entry.text} (${entry.votes} vote${entry.votes === 1 ? "" : "s"})`); + } + lines.push(""); + if (!revealResults) { + lines.push("Responses are hidden until the poll closes."); + } else { + lines.push(`Total voters: ${summary.totalVotes}`); + } + return lines.join("\n"); +} + function buildTextContent(body: string): TextContent { return { "m.text": body, @@ -138,30 +389,44 @@ function buildPollFallbackText(question: string, answers: string[]): string { } export function buildPollStartContent(poll: PollInput): PollStartContent { - const question = poll.question.trim(); - const answers = poll.options - .map((option) => option.trim()) - .filter((option) => option.length > 0) - .map((option, idx) => ({ - id: `answer${idx + 1}`, - ...buildTextContent(option), - })); + const normalized = normalizePollInput(poll); + const answers = normalized.options.map((option, idx) => ({ + id: `answer${idx + 1}`, + ...buildTextContent(option), + })); - const isMultiple = (poll.maxSelections ?? 1) > 1; - const maxSelections = isMultiple ? Math.max(1, answers.length) : 1; + const isMultiple = normalized.maxSelections > 1; const fallbackText = buildPollFallbackText( - question, + normalized.question, answers.map((answer) => getTextContent(answer)), ); return { [M_POLL_START]: { - question: buildTextContent(question), + question: buildTextContent(normalized.question), kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed", - max_selections: maxSelections, + max_selections: normalized.maxSelections, answers, }, "m.text": fallbackText, "org.matrix.msc1767.text": fallbackText, }; } + +export function buildPollResponseContent( + pollEventId: string, + answerIds: string[], +): PollResponseContent { + return { + [M_POLL_RESPONSE]: { + answers: answerIds, + }, + [ORG_POLL_RESPONSE]: { + answers: answerIds, + }, + "m.relates_to": { + rel_type: "m.reference", + event_id: pollEventId, + }, + }; +} diff --git a/extensions/matrix/src/matrix/probe.test.ts b/extensions/matrix/src/matrix/probe.test.ts new file mode 100644 index 00000000000..3d0221e0709 --- /dev/null +++ b/extensions/matrix/src/matrix/probe.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createMatrixClientMock = vi.fn(); +const isBunRuntimeMock = vi.fn(() => false); + +vi.mock("./client.js", () => ({ + createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), + isBunRuntime: () => isBunRuntimeMock(), +})); + +import { probeMatrix } from "./probe.js"; + +describe("probeMatrix", () => { + beforeEach(() => { + vi.clearAllMocks(); + isBunRuntimeMock.mockReturnValue(false); + createMatrixClientMock.mockResolvedValue({ + getUserId: vi.fn(async () => "@bot:example.org"), + }); + }); + + it("passes undefined userId when not provided", async () => { + const result = await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + timeoutMs: 1234, + }); + + expect(result.ok).toBe(true); + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: undefined, + accessToken: "tok", + localTimeoutMs: 1234, + }); + }); + + it("trims provided userId before client creation", async () => { + await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + userId: " @bot:example.org ", + timeoutMs: 500, + }); + + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok", + localTimeoutMs: 500, + }); + }); + + it("passes accountId through to client creation", async () => { + await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + userId: "@bot:example.org", + timeoutMs: 500, + accountId: "ops", + }); + + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok", + localTimeoutMs: 500, + accountId: "ops", + }); + }); + + it("returns client validation errors for insecure public http homeservers", async () => { + createMatrixClientMock.mockRejectedValue( + new Error("Matrix homeserver must use https:// unless it targets a private or loopback host"), + ); + + const result = await probeMatrix({ + homeserver: "http://matrix.example.org", + accessToken: "tok", + timeoutMs: 500, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("Matrix homeserver must use https://"); + }); +}); diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 7a5d2a98bce..6b0b9d9aec1 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -1,4 +1,4 @@ -import type { BaseProbeResult } from "../../runtime-api.js"; +import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix"; import { createMatrixClient, isBunRuntime } from "./client.js"; export type MatrixProbe = BaseProbeResult & { @@ -12,6 +12,7 @@ export async function probeMatrix(params: { accessToken: string; userId?: string; timeoutMs: number; + accountId?: string | null; }): Promise { const started = Date.now(); const result: MatrixProbe = { @@ -42,13 +43,15 @@ export async function probeMatrix(params: { }; } try { + const inputUserId = params.userId?.trim() || undefined; const client = await createMatrixClient({ homeserver: params.homeserver, - userId: params.userId ?? "", + userId: inputUserId, accessToken: params.accessToken, localTimeoutMs: params.timeoutMs, + accountId: params.accountId, }); - // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally + // The client wrapper resolves user ID via whoami when needed. const userId = await client.getUserId(); result.ok = true; result.userId = userId ?? null; diff --git a/extensions/matrix/src/matrix/profile.test.ts b/extensions/matrix/src/matrix/profile.test.ts new file mode 100644 index 00000000000..0f5035e89ee --- /dev/null +++ b/extensions/matrix/src/matrix/profile.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isSupportedMatrixAvatarSource, + syncMatrixOwnProfile, + type MatrixProfileSyncResult, +} from "./profile.js"; + +function createClientStub() { + return { + getUserProfile: vi.fn(async () => ({})), + setDisplayName: vi.fn(async () => {}), + setAvatarUrl: vi.fn(async () => {}), + uploadContent: vi.fn(async () => "mxc://example/avatar"), + }; +} + +function expectNoUpdates(result: MatrixProfileSyncResult) { + expect(result.displayNameUpdated).toBe(false); + expect(result.avatarUpdated).toBe(false); +} + +describe("matrix profile sync", () => { + it("skips when no desired profile values are provided", async () => { + const client = createClientStub(); + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + }); + + expect(result.skipped).toBe(true); + expectNoUpdates(result); + expect(result.uploadedAvatarSource).toBeNull(); + expect(client.setDisplayName).not.toHaveBeenCalled(); + expect(client.setAvatarUrl).not.toHaveBeenCalled(); + }); + + it("updates display name when desired name differs", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Old Name", + avatar_url: "mxc://example/existing", + }); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + displayName: "New Name", + }); + + expect(result.skipped).toBe(false); + expect(result.displayNameUpdated).toBe(true); + expect(result.avatarUpdated).toBe(false); + expect(result.uploadedAvatarSource).toBeNull(); + expect(client.setDisplayName).toHaveBeenCalledWith("New Name"); + }); + + it("does not update when name and avatar already match", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/avatar", + }); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + displayName: "Bot", + avatarUrl: "mxc://example/avatar", + }); + + expect(result.skipped).toBe(false); + expectNoUpdates(result); + expect(client.setDisplayName).not.toHaveBeenCalled(); + expect(client.setAvatarUrl).not.toHaveBeenCalled(); + }); + + it("converts http avatar URL by uploading and then updates profile avatar", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/old", + }); + client.uploadContent.mockResolvedValue("mxc://example/new-avatar"); + const loadAvatarFromUrl = vi.fn(async () => ({ + buffer: Buffer.from("avatar-bytes"), + contentType: "image/png", + fileName: "avatar.png", + })); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarUrl: "https://cdn.example.org/avatar.png", + loadAvatarFromUrl, + }); + + expect(result.convertedAvatarFromHttp).toBe(true); + expect(result.uploadedAvatarSource).toBe("http"); + expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar"); + expect(result.avatarUpdated).toBe(true); + expect(loadAvatarFromUrl).toHaveBeenCalledWith( + "https://cdn.example.org/avatar.png", + 10 * 1024 * 1024, + ); + expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar"); + }); + + it("uploads avatar media from a local path and then updates profile avatar", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/old", + }); + client.uploadContent.mockResolvedValue("mxc://example/path-avatar"); + const loadAvatarFromPath = vi.fn(async () => ({ + buffer: Buffer.from("avatar-bytes"), + contentType: "image/jpeg", + fileName: "avatar.jpg", + })); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarPath: "/tmp/avatar.jpg", + loadAvatarFromPath, + }); + + expect(result.convertedAvatarFromHttp).toBe(false); + expect(result.uploadedAvatarSource).toBe("path"); + expect(result.resolvedAvatarUrl).toBe("mxc://example/path-avatar"); + expect(result.avatarUpdated).toBe(true); + expect(loadAvatarFromPath).toHaveBeenCalledWith("/tmp/avatar.jpg", 10 * 1024 * 1024); + expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/path-avatar"); + }); + + it("rejects unsupported avatar URL schemes", async () => { + const client = createClientStub(); + + await expect( + syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarUrl: "file:///tmp/avatar.png", + }), + ).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL."); + }); + + it("recognizes supported avatar sources", () => { + expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true); + expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true); + expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true); + expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false); + }); +}); diff --git a/extensions/matrix/src/matrix/profile.ts b/extensions/matrix/src/matrix/profile.ts new file mode 100644 index 00000000000..ea21ede89e6 --- /dev/null +++ b/extensions/matrix/src/matrix/profile.ts @@ -0,0 +1,188 @@ +import type { MatrixClient } from "./sdk.js"; + +export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024; + +type MatrixProfileClient = Pick< + MatrixClient, + "getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent" +>; + +type MatrixProfileLoadResult = { + buffer: Buffer; + contentType?: string; + fileName?: string; +}; + +export type MatrixProfileSyncResult = { + skipped: boolean; + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; + convertedAvatarFromHttp: boolean; +}; + +function normalizeOptionalText(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function isMatrixMxcUri(value: string): boolean { + return value.trim().toLowerCase().startsWith("mxc://"); +} + +export function isMatrixHttpAvatarUri(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized.startsWith("https://") || normalized.startsWith("http://"); +} + +export function isSupportedMatrixAvatarSource(value: string): boolean { + return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value); +} + +async function uploadAvatarMedia(params: { + client: MatrixProfileClient; + avatarSource: string; + avatarMaxBytes: number; + loadAvatar: (source: string, maxBytes: number) => Promise; +}): Promise { + const media = await params.loadAvatar(params.avatarSource, params.avatarMaxBytes); + return await params.client.uploadContent( + media.buffer, + media.contentType, + media.fileName || "avatar", + ); +} + +async function resolveAvatarUrl(params: { + client: MatrixProfileClient; + avatarUrl: string | null; + avatarPath?: string | null; + avatarMaxBytes: number; + loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; + loadAvatarFromPath?: (path: string, maxBytes: number) => Promise; +}): Promise<{ + resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; + convertedAvatarFromHttp: boolean; +}> { + const avatarPath = normalizeOptionalText(params.avatarPath); + if (avatarPath) { + if (!params.loadAvatarFromPath) { + throw new Error("Matrix avatar path upload requires a media loader."); + } + return { + resolvedAvatarUrl: await uploadAvatarMedia({ + client: params.client, + avatarSource: avatarPath, + avatarMaxBytes: params.avatarMaxBytes, + loadAvatar: params.loadAvatarFromPath, + }), + uploadedAvatarSource: "path", + convertedAvatarFromHttp: false, + }; + } + + const avatarUrl = normalizeOptionalText(params.avatarUrl); + if (!avatarUrl) { + return { + resolvedAvatarUrl: null, + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + }; + } + + if (isMatrixMxcUri(avatarUrl)) { + return { + resolvedAvatarUrl: avatarUrl, + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + }; + } + + if (!isMatrixHttpAvatarUri(avatarUrl)) { + throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL."); + } + + if (!params.loadAvatarFromUrl) { + throw new Error("Matrix avatar URL conversion requires a media loader."); + } + + return { + resolvedAvatarUrl: await uploadAvatarMedia({ + client: params.client, + avatarSource: avatarUrl, + avatarMaxBytes: params.avatarMaxBytes, + loadAvatar: params.loadAvatarFromUrl, + }), + uploadedAvatarSource: "http", + convertedAvatarFromHttp: true, + }; +} + +export async function syncMatrixOwnProfile(params: { + client: MatrixProfileClient; + userId: string; + displayName?: string | null; + avatarUrl?: string | null; + avatarPath?: string | null; + avatarMaxBytes?: number; + loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; + loadAvatarFromPath?: (path: string, maxBytes: number) => Promise; +}): Promise { + const desiredDisplayName = normalizeOptionalText(params.displayName); + const avatar = await resolveAvatarUrl({ + client: params.client, + avatarUrl: params.avatarUrl ?? null, + avatarPath: params.avatarPath ?? null, + avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES, + loadAvatarFromUrl: params.loadAvatarFromUrl, + loadAvatarFromPath: params.loadAvatarFromPath, + }); + const desiredAvatarUrl = avatar.resolvedAvatarUrl; + + if (!desiredDisplayName && !desiredAvatarUrl) { + return { + skipped: true, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + uploadedAvatarSource: avatar.uploadedAvatarSource, + convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, + }; + } + + let currentDisplayName: string | undefined; + let currentAvatarUrl: string | undefined; + try { + const currentProfile = await params.client.getUserProfile(params.userId); + currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined; + currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined; + } catch { + // If profile fetch fails, attempt writes directly. + } + + let displayNameUpdated = false; + let avatarUpdated = false; + + if (desiredDisplayName && currentDisplayName !== desiredDisplayName) { + await params.client.setDisplayName(desiredDisplayName); + displayNameUpdated = true; + } + if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) { + await params.client.setAvatarUrl(desiredAvatarUrl); + avatarUpdated = true; + } + + return { + skipped: false, + displayNameUpdated, + avatarUpdated, + resolvedAvatarUrl: desiredAvatarUrl, + uploadedAvatarSource: avatar.uploadedAvatarSource, + convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, + }; +} diff --git a/extensions/matrix/src/matrix/reaction-common.test.ts b/extensions/matrix/src/matrix/reaction-common.test.ts new file mode 100644 index 00000000000..299bd20f7cb --- /dev/null +++ b/extensions/matrix/src/matrix/reaction-common.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + buildMatrixReactionContent, + buildMatrixReactionRelationsPath, + extractMatrixReactionAnnotation, + selectOwnMatrixReactionEventIds, + summarizeMatrixReactionEvents, +} from "./reaction-common.js"; + +describe("matrix reaction helpers", () => { + it("builds trimmed reaction content and relation paths", () => { + expect(buildMatrixReactionContent(" $msg ", " 👍 ")).toEqual({ + "m.relates_to": { + rel_type: "m.annotation", + event_id: "$msg", + key: "👍", + }, + }); + expect(buildMatrixReactionRelationsPath("!room:example.org", " $msg ")).toContain( + "/rooms/!room%3Aexample.org/relations/%24msg/m.annotation/m.reaction", + ); + }); + + it("summarizes reactions by emoji and unique sender", () => { + expect( + summarizeMatrixReactionEvents([ + { sender: "@alice:example.org", content: { "m.relates_to": { key: "👍" } } }, + { sender: "@alice:example.org", content: { "m.relates_to": { key: "👍" } } }, + { sender: "@bob:example.org", content: { "m.relates_to": { key: "👍" } } }, + { sender: "@alice:example.org", content: { "m.relates_to": { key: "👎" } } }, + { sender: "@ignored:example.org", content: {} }, + ]), + ).toEqual([ + { + key: "👍", + count: 3, + users: ["@alice:example.org", "@bob:example.org"], + }, + { + key: "👎", + count: 1, + users: ["@alice:example.org"], + }, + ]); + }); + + it("selects only matching reaction event ids for the current user", () => { + expect( + selectOwnMatrixReactionEventIds( + [ + { + event_id: "$1", + sender: "@me:example.org", + content: { "m.relates_to": { key: "👍" } }, + }, + { + event_id: "$2", + sender: "@me:example.org", + content: { "m.relates_to": { key: "👎" } }, + }, + { + event_id: "$3", + sender: "@other:example.org", + content: { "m.relates_to": { key: "👍" } }, + }, + ], + "@me:example.org", + "👍", + ), + ).toEqual(["$1"]); + }); + + it("extracts annotations and ignores non-annotation relations", () => { + expect( + extractMatrixReactionAnnotation({ + "m.relates_to": { + rel_type: "m.annotation", + event_id: " $msg ", + key: " 👍 ", + }, + }), + ).toEqual({ + eventId: "$msg", + key: "👍", + }); + expect( + extractMatrixReactionAnnotation({ + "m.relates_to": { + rel_type: "m.replace", + event_id: "$msg", + key: "👍", + }, + }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/reaction-common.ts b/extensions/matrix/src/matrix/reaction-common.ts new file mode 100644 index 00000000000..797e5392dfd --- /dev/null +++ b/extensions/matrix/src/matrix/reaction-common.ts @@ -0,0 +1,145 @@ +export const MATRIX_ANNOTATION_RELATION_TYPE = "m.annotation"; +export const MATRIX_REACTION_EVENT_TYPE = "m.reaction"; + +export type MatrixReactionEventContent = { + "m.relates_to": { + rel_type: typeof MATRIX_ANNOTATION_RELATION_TYPE; + event_id: string; + key: string; + }; +}; + +export type MatrixReactionSummary = { + key: string; + count: number; + users: string[]; +}; + +export type MatrixReactionAnnotation = { + key: string; + eventId?: string; +}; + +type MatrixReactionEventLike = { + content?: unknown; + sender?: string | null; + event_id?: string | null; +}; + +export function normalizeMatrixReactionMessageId(messageId: string): string { + const normalized = messageId.trim(); + if (!normalized) { + throw new Error("Matrix reaction requires a messageId"); + } + return normalized; +} + +export function normalizeMatrixReactionEmoji(emoji: string): string { + const normalized = emoji.trim(); + if (!normalized) { + throw new Error("Matrix reaction requires an emoji"); + } + return normalized; +} + +export function buildMatrixReactionContent( + messageId: string, + emoji: string, +): MatrixReactionEventContent { + return { + "m.relates_to": { + rel_type: MATRIX_ANNOTATION_RELATION_TYPE, + event_id: normalizeMatrixReactionMessageId(messageId), + key: normalizeMatrixReactionEmoji(emoji), + }, + }; +} + +export function buildMatrixReactionRelationsPath(roomId: string, messageId: string): string { + return `/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(normalizeMatrixReactionMessageId(messageId))}/${MATRIX_ANNOTATION_RELATION_TYPE}/${MATRIX_REACTION_EVENT_TYPE}`; +} + +export function extractMatrixReactionAnnotation( + content: unknown, +): MatrixReactionAnnotation | undefined { + if (!content || typeof content !== "object") { + return undefined; + } + const relatesTo = ( + content as { + "m.relates_to"?: { + rel_type?: unknown; + event_id?: unknown; + key?: unknown; + }; + } + )["m.relates_to"]; + if (!relatesTo || typeof relatesTo !== "object") { + return undefined; + } + if ( + typeof relatesTo.rel_type === "string" && + relatesTo.rel_type !== MATRIX_ANNOTATION_RELATION_TYPE + ) { + return undefined; + } + const key = typeof relatesTo.key === "string" ? relatesTo.key.trim() : ""; + if (!key) { + return undefined; + } + const eventId = typeof relatesTo.event_id === "string" ? relatesTo.event_id.trim() : ""; + return { + key, + eventId: eventId || undefined, + }; +} + +export function extractMatrixReactionKey(content: unknown): string | undefined { + return extractMatrixReactionAnnotation(content)?.key; +} + +export function summarizeMatrixReactionEvents( + events: Iterable>, +): MatrixReactionSummary[] { + const summaries = new Map(); + for (const event of events) { + const key = extractMatrixReactionKey(event.content); + if (!key) { + continue; + } + const sender = event.sender?.trim() ?? ""; + const entry = 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()); +} + +export function selectOwnMatrixReactionEventIds( + events: Iterable>, + userId: string, + emoji?: string, +): string[] { + const senderId = userId.trim(); + if (!senderId) { + return []; + } + const targetEmoji = emoji?.trim(); + const ids: string[] = []; + for (const event of events) { + if ((event.sender?.trim() ?? "") !== senderId) { + continue; + } + if (targetEmoji && extractMatrixReactionKey(event.content) !== targetEmoji) { + continue; + } + const eventId = event.event_id?.trim(); + if (eventId) { + ids.push(eventId); + } + } + return ids; +} diff --git a/extensions/matrix/src/matrix/sdk-runtime.ts b/extensions/matrix/src/matrix/sdk-runtime.ts deleted file mode 100644 index 8903da896ab..00000000000 --- a/extensions/matrix/src/matrix/sdk-runtime.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createRequire } from "node:module"; - -type MatrixSdkRuntime = typeof import("@vector-im/matrix-bot-sdk"); - -let cachedMatrixSdkRuntime: MatrixSdkRuntime | null = null; - -export function loadMatrixSdk(): MatrixSdkRuntime { - if (cachedMatrixSdkRuntime) { - return cachedMatrixSdkRuntime; - } - const req = createRequire(import.meta.url); - cachedMatrixSdkRuntime = req("@vector-im/matrix-bot-sdk") as MatrixSdkRuntime; - return cachedMatrixSdkRuntime; -} - -export function getMatrixLogService() { - return loadMatrixSdk().LogService; -} diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts new file mode 100644 index 00000000000..3467f12711c --- /dev/null +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -0,0 +1,2123 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +class FakeMatrixEvent extends EventEmitter { + private readonly roomId: string; + private readonly eventId: string; + private readonly sender: string; + private readonly type: string; + private readonly ts: number; + private readonly content: Record; + private readonly stateKey?: string; + private readonly unsigned?: { + age?: number; + redacted_because?: unknown; + }; + private readonly decryptionFailure: boolean; + + constructor(params: { + roomId: string; + eventId: string; + sender: string; + type: string; + ts: number; + content: Record; + stateKey?: string; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; + decryptionFailure?: boolean; + }) { + super(); + this.roomId = params.roomId; + this.eventId = params.eventId; + this.sender = params.sender; + this.type = params.type; + this.ts = params.ts; + this.content = params.content; + this.stateKey = params.stateKey; + this.unsigned = params.unsigned; + this.decryptionFailure = params.decryptionFailure === true; + } + + getRoomId(): string { + return this.roomId; + } + + getId(): string { + return this.eventId; + } + + getSender(): string { + return this.sender; + } + + getType(): string { + return this.type; + } + + getTs(): number { + return this.ts; + } + + getContent(): Record { + return this.content; + } + + getUnsigned(): { age?: number; redacted_because?: unknown } { + return this.unsigned ?? {}; + } + + getStateKey(): string | undefined { + return this.stateKey; + } + + isDecryptionFailure(): boolean { + return this.decryptionFailure; + } +} + +type MatrixJsClientStub = EventEmitter & { + startClient: ReturnType; + stopClient: ReturnType; + initRustCrypto: ReturnType; + getUserId: ReturnType; + getDeviceId: ReturnType; + getJoinedRooms: ReturnType; + getJoinedRoomMembers: ReturnType; + getStateEvent: ReturnType; + getAccountData: ReturnType; + setAccountData: ReturnType; + getRoomIdForAlias: ReturnType; + sendMessage: ReturnType; + sendEvent: ReturnType; + sendStateEvent: ReturnType; + redactEvent: ReturnType; + getProfileInfo: ReturnType; + joinRoom: ReturnType; + mxcUrlToHttp: ReturnType; + uploadContent: ReturnType; + fetchRoomEvent: ReturnType; + getEventMapper: ReturnType; + sendTyping: ReturnType; + getRoom: ReturnType; + getRooms: ReturnType; + getCrypto: ReturnType; + decryptEventIfNeeded: ReturnType; + relations: ReturnType; +}; + +function createMatrixJsClientStub(): MatrixJsClientStub { + const client = new EventEmitter() as MatrixJsClientStub; + client.startClient = vi.fn(async () => {}); + client.stopClient = vi.fn(); + client.initRustCrypto = vi.fn(async () => {}); + client.getUserId = vi.fn(() => "@bot:example.org"); + client.getDeviceId = vi.fn(() => "DEVICE123"); + client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] })); + client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} })); + client.getStateEvent = vi.fn(async () => ({})); + client.getAccountData = vi.fn(() => undefined); + client.setAccountData = vi.fn(async () => {}); + client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" })); + client.sendMessage = vi.fn(async () => ({ event_id: "$sent" })); + client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" })); + client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" })); + client.redactEvent = vi.fn(async () => ({ event_id: "$redact" })); + client.getProfileInfo = vi.fn(async () => ({})); + client.joinRoom = vi.fn(async () => ({})); + client.mxcUrlToHttp = vi.fn(() => null); + client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" })); + client.fetchRoomEvent = vi.fn(async () => ({})); + client.getEventMapper = vi.fn( + () => + ( + raw: Partial<{ + room_id: string; + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + state_key?: string; + unsigned?: { age?: number; redacted_because?: unknown }; + }>, + ) => + new FakeMatrixEvent({ + roomId: raw.room_id ?? "!mapped:example.org", + eventId: raw.event_id ?? "$mapped", + sender: raw.sender ?? "@mapped:example.org", + type: raw.type ?? "m.room.message", + ts: raw.origin_server_ts ?? Date.now(), + content: raw.content ?? {}, + stateKey: raw.state_key, + unsigned: raw.unsigned, + }), + ); + client.sendTyping = vi.fn(async () => {}); + client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); + client.getRooms = vi.fn(() => []); + client.getCrypto = vi.fn(() => undefined); + client.decryptEventIfNeeded = vi.fn(async () => {}); + client.relations = vi.fn(async () => ({ + originalEvent: null, + events: [], + nextBatch: null, + prevBatch: null, + })); + return client; +} + +let matrixJsClient = createMatrixJsClientStub(); +let lastCreateClientOpts: Record | null = null; + +vi.mock("matrix-js-sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ClientEvent: { Event: "event", Room: "Room" }, + MatrixEventEvent: { Decrypted: "decrypted" }, + createClient: vi.fn((opts: Record) => { + lastCreateClientOpts = opts; + return matrixJsClient; + }), + }; +}); + +import { MatrixClient } from "./sdk.js"; + +describe("MatrixClient request hardening", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("blocks absolute endpoints unless explicitly allowed", async () => { + const fetchMock = vi.fn(async () => { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow( + "Absolute Matrix endpoint is blocked by default", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("prefers authenticated client media downloads", async () => { + const payload = Buffer.from([1, 2, 3, 4]); + const fetchMock = vi.fn(async () => new Response(payload, { status: 200 })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstUrl = String(fetchMock.mock.calls[0]?.[0]); + expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); + }); + + it("falls back to legacy media downloads for older homeservers", async () => { + const payload = Buffer.from([5, 6, 7, 8]); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/_matrix/client/v1/media/download/")) { + return new Response( + JSON.stringify({ + errcode: "M_UNRECOGNIZED", + error: "Unrecognized request", + }), + { + status: 404, + headers: { "content-type": "application/json" }, + }, + ); + } + return new Response(payload, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstUrl = String(fetchMock.mock.calls[0]?.[0]); + const secondUrl = String(fetchMock.mock.calls[1]?.[0]); + expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); + expect(secondUrl).toContain("/_matrix/media/v3/download/example.org/media"); + }); + + it("decrypts encrypted room events returned by getEvent", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + matrixJsClient.fetchRoomEvent = vi.fn(async () => ({ + room_id: "!room:example.org", + event_id: "$poll", + sender: "@alice:example.org", + type: "m.room.encrypted", + origin_server_ts: 1, + content: {}, + })); + matrixJsClient.decryptEventIfNeeded = vi.fn(async (event: FakeMatrixEvent) => { + event.emit( + "decrypted", + new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }), + ); + }); + + const event = await client.getEvent("!room:example.org", "$poll"); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(event).toMatchObject({ + event_id: "$poll", + type: "m.poll.start", + sender: "@alice:example.org", + }); + }); + + it("serializes outbound sends per room across message and event sends", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + let releaseFirst: (() => void) | undefined; + const started: string[] = []; + matrixJsClient.sendMessage = vi.fn(async () => { + started.push("message"); + await new Promise((resolve) => { + releaseFirst = resolve; + }); + return { event_id: "$message" }; + }); + matrixJsClient.sendEvent = vi.fn(async () => { + started.push("event"); + return { event_id: "$event" }; + }); + + const first = client.sendMessage("!room:example.org", { + msgtype: "m.text", + body: "hello", + }); + const second = client.sendEvent("!room:example.org", "m.reaction", { + "m.relates_to": { event_id: "$target", key: "👍", rel_type: "m.annotation" }, + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(started).toEqual(["message"]); + expect(matrixJsClient.sendEvent).not.toHaveBeenCalled(); + + releaseFirst?.(); + + await expect(first).resolves.toBe("$message"); + await expect(second).resolves.toBe("$event"); + expect(started).toEqual(["message", "event"]); + }); + + it("does not serialize sends across different rooms", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + let releaseFirst: (() => void) | undefined; + const started: string[] = []; + matrixJsClient.sendMessage = vi.fn(async (roomId: string) => { + started.push(roomId); + if (roomId === "!room-a:example.org") { + await new Promise((resolve) => { + releaseFirst = resolve; + }); + } + return { event_id: `$${roomId}` }; + }); + + const first = client.sendMessage("!room-a:example.org", { + msgtype: "m.text", + body: "a", + }); + const second = client.sendMessage("!room-b:example.org", { + msgtype: "m.text", + body: "b", + }); + + await Promise.resolve(); + await Promise.resolve(); + expect(started).toEqual(["!room-a:example.org", "!room-b:example.org"]); + + releaseFirst?.(); + + await expect(first).resolves.toBe("$!room-a:example.org"); + await expect(second).resolves.toBe("$!room-b:example.org"); + }); + + it("maps relations pages back to raw events", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + matrixJsClient.relations = vi.fn(async () => ({ + originalEvent: new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$poll", + sender: "@alice:example.org", + type: "m.poll.start", + ts: 1, + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }), + events: [ + new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$vote", + sender: "@bob:example.org", + type: "m.poll.response", + ts: 2, + content: { + "m.poll.response": { answers: ["a1"] }, + "m.relates_to": { rel_type: "m.reference", event_id: "$poll" }, + }, + }), + ], + nextBatch: null, + prevBatch: null, + })); + + const page = await client.getRelations("!room:example.org", "$poll", "m.reference"); + + expect(page.originalEvent).toMatchObject({ event_id: "$poll", type: "m.poll.start" }); + expect(page.events).toEqual([ + expect.objectContaining({ + event_id: "$vote", + type: "m.poll.response", + sender: "@bob:example.org", + }), + ]); + }); + + it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => { + const fetchMock = vi.fn(async () => { + return new Response("", { + status: 302, + headers: { + location: "http://evil.example.org/next", + }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + + await expect( + client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }), + ).rejects.toThrow("Blocked cross-protocol redirect"); + }); + + it("strips authorization when redirect crosses origin", async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => { + calls.push({ + url: String(url), + headers: new Headers(init?.headers), + }); + if (calls.length === 1) { + return new Response("", { + status: 302, + headers: { location: "https://cdn.example.org/next" }, + }); + } + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toBe("https://matrix.example.org/start"); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer token"); + expect(calls[1]?.url).toBe("https://cdn.example.org/next"); + expect(calls[1]?.headers.get("authorization")).toBeNull(); + }); + + it("aborts requests after timeout", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + localTimeoutMs: 25, + }); + + const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami"); + const assertion = expect(pending).rejects.toThrow("aborted"); + await vi.advanceTimersByTimeAsync(30); + + await assertion; + }); + + it("wires the sync store into the SDK and flushes it on shutdown", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sdk-store-")); + const storagePath = path.join(tempDir, "bot-storage.json"); + + try { + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + storagePath, + }); + + const store = lastCreateClientOpts?.store as { flush: () => Promise } | undefined; + expect(store).toBeTruthy(); + const flushSpy = vi.spyOn(store!, "flush").mockResolvedValue(); + + await client.stopAndPersist(); + + expect(flushSpy).toHaveBeenCalledTimes(1); + expect(matrixJsClient.stopClient).toHaveBeenCalledTimes(1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + +describe("MatrixClient event bridge", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("emits room.message only after encrypted events decrypt", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const messageEvents: Array<{ roomId: string; type: string }> = []; + + client.on("room.message", (roomId, event) => { + messageEvents.push({ roomId, type: event.type }); + }); + + await client.start(); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.emit("event", encrypted); + expect(messageEvents).toHaveLength(0); + + encrypted.emit("decrypted", decrypted); + // Simulate a second normal event emission from the SDK after decryption. + matrixJsClient.emit("event", decrypted); + expect(messageEvents).toEqual([ + { + roomId: "!room:example.org", + type: "m.room.message", + }, + ]); + }); + + it("emits room.failed_decryption when decrypting fails", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + const delivered: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + await client.start(); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", decrypted, new Error("decrypt failed")); + + expect(failed).toEqual(["decrypt failed"]); + expect(delivered).toHaveLength(0); + }); + + it("retries failed decryption and emits room.message after late key availability", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + const delivered: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1_600); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(failed).toEqual(["missing room key"]); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("retries failed decryptions immediately on crypto key update signals", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const failed: string[] = []; + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("stops decryption retries after hitting retry cap", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + throw new Error("still missing key"); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + }); + + it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + const releaseRetryRef: { current?: () => void } = {}; + matrixJsClient.decryptEventIfNeeded = vi.fn( + async () => + await new Promise((resolve) => { + releaseRetryRef.current = () => { + encrypted.emit("decrypted", decrypted); + resolve(); + }; + }), + ); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + releaseRetryRef.current?.(); + await Promise.resolve(); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("emits room.invite when a membership invite targets the current user", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + const inviteMembership = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$invite", + sender: "@alice:example.org", + type: "m.room.member", + ts: Date.now(), + stateKey: "@bot:example.org", + content: { + membership: "invite", + }, + }); + + matrixJsClient.emit("event", inviteMembership); + + expect(invites).toEqual(["!room:example.org"]); + }); + + it("emits room.invite when SDK emits Room event with invite membership", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + matrixJsClient.emit("Room", { + roomId: "!invite:example.org", + getMyMembership: () => "invite", + }); + + expect(invites).toEqual(["!invite:example.org"]); + }); + + it("replays outstanding invite rooms at startup", async () => { + matrixJsClient.getRooms = vi.fn(() => [ + { + roomId: "!pending:example.org", + getMyMembership: () => "invite", + }, + { + roomId: "!joined:example.org", + getMyMembership: () => "join", + }, + ]); + + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + expect(invites).toEqual(["!pending:example.org"]); + }); +}); + +describe("MatrixClient crypto bootstrapping", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("passes cryptoDatabasePrefix into initRustCrypto", async () => { + matrixJsClient.getCrypto = vi.fn(() => undefined); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + + await client.start(); + + expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({ + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + }); + + it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => { + const bootstrapCrossSigning = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + await client.start(); + + expect(bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + password: "secret-password", // pragma: allowlist secret + }); + const bootstrapSpy = vi + .fn() + .mockResolvedValueOnce({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }) + .mockResolvedValueOnce({ + crossSigningReady: true, + crossSigningPublished: true, + ownDeviceVerified: true, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(2); + expect(bootstrapSpy.mock.calls[1]?.[1]).toEqual({ + forceResetCrossSigning: true, + strict: true, + }); + }); + + it("does not force-reset bootstrap when the device is already signed by its owner", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + password: "secret-password", // pragma: allowlist secret + }); + const bootstrapSpy = vi.fn().mockResolvedValue({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: true, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + vi.spyOn(client, "getOwnDeviceVerificationStatus").mockResolvedValue({ + encryptionEnabled: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + verified: true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: true, + recoveryKeyStored: false, + recoveryKeyCreatedAt: null, + recoveryKeyId: null, + backupVersion: null, + backup: { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: false, + keyLoadAttempted: false, + keyLoadError: null, + }, + }); + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + expect(bootstrapSpy.mock.calls[0]?.[1]).toEqual({ + allowAutomaticCrossSigningReset: false, + }); + }); + + it("does not force-reset bootstrap when password is unavailable", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const bootstrapSpy = vi.fn().mockResolvedValue({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); + + it("provides secret storage callbacks and resolves stored recovery key", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-")); + const recoveryKeyPath = path.join(tmpDir, "recovery-key.json"); + const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64"); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSSKEY", + privateKeyBase64, + }), + "utf8", + ); + + new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + } | null; + expect(callbacks?.getSecretStorageKey).toBeTypeOf("function"); + + const resolved = await callbacks?.getSecretStorageKey?.( + { keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } }, + "m.cross_signing.master", + ); + expect(resolved?.[0]).toBe("SSSSKEY"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("provides a matrix-js-sdk logger to createClient", () => { + new MatrixClient("https://matrix.example.org", "token"); + const logger = (lastCreateClientOpts?.logger ?? null) as { + debug?: (...args: unknown[]) => void; + getChild?: (namespace: string) => unknown; + } | null; + expect(logger).not.toBeNull(); + expect(logger?.debug).toBeTypeOf("function"); + expect(logger?.getChild).toBeTypeOf("function"); + }); + + it("schedules periodic crypto snapshot persistence with fake timers", async () => { + vi.useFakeTimers(); + const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"), + cryptoDatabasePrefix: "openclaw-matrix-interval", + }); + + await client.start(); + const callsAfterStart = databasesSpy.mock.calls.length; + + await vi.advanceTimersByTimeAsync(60_000); + expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart); + + client.stop(); + const callsAfterStop = databasesSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + expect(databasesSpy.mock.calls.length).toBe(callsAfterStop); + }); + + it("reports own verification status when crypto marks device as verified", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.encryptionEnabled).toBe(true); + expect(status.verified).toBe(true); + expect(status.userId).toBe("@bot:example.org"); + expect(status.deviceId).toBe("DEVICE123"); + }); + + it("does not treat local-only trust as owner verification", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.localVerified).toBe(true); + expect(status.crossSigningVerified).toBe(false); + expect(status.signedByOwner).toBe(false); + expect(status.verified).toBe(false); + }); + + it("verifies with a provided recovery key and reports success", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + expect(encoded).toBeTypeOf("string"); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + const bootstrapCrossSigning = vi.fn(async () => {}); + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const getSecretStorageStatus = vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })); + const getDeviceVerificationStatus = vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus, + getDeviceVerificationStatus, + checkKeyBackupAndEnable, + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-key-")); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"), + }); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(true); + expect(result.verified).toBe(true); + expect(result.recoveryKeyStored).toBe(true); + expect(result.deviceId).toBe("DEVICE123"); + expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalled(); + expect(bootstrapCrossSigning).toHaveBeenCalled(); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + }); + + it("fails recovery-key verification when the device is only locally trusted", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-")); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"), + }); + await client.start(); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(false); + expect(result.verified).toBe(false); + expect(result.error).toContain("not verified by its owner"); + }); + + it("fails recovery-key verification when backup remains untrusted after device verification", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + checkKeyBackupAndEnable: vi.fn(async () => {}), + getActiveSessionBackupVersion: vi.fn(async () => "11"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "11", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: false, + matchesDecryptionKey: true, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-untrusted-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(false); + expect(result.verified).toBe(true); + expect(result.error).toContain("backup signature chain is not trusted"); + expect(result.recoveryKeyStored).toBe(false); + expect(fs.existsSync(recoveryKeyPath)).toBe(false); + }); + + it("does not overwrite the stored recovery key when recovery-key verification fails", async () => { + const previousEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)), + ); + const attemptedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 55)), + ); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => { + throw new Error("secret storage rejected recovery key"); + }), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-preserve-")); + const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json"); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSSKEY", + encodedPrivateKey: previousEncoded, + privateKeyBase64: Buffer.from( + new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 5)), + ).toString("base64"), + }), + "utf8", + ); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const result = await client.verifyWithRecoveryKey(attemptedEncoded as string); + + expect(result.success).toBe(false); + expect(result.error).toContain("not verified by its owner"); + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + encodedPrivateKey?: string; + }; + expect(persisted.encodedPrivateKey).toBe(previousEncoded); + }); + + it("reports detailed room-key backup health", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => "11"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1, 2, 3])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "11", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "11" }); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.backupVersion).toBe("11"); + expect(status.backup).toEqual({ + serverVersion: "11", + activeVersion: "11", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: false, + keyLoadError: null, + }); + }); + + it("tries loading backup keys from secret storage when key is missing from cache", async () => { + const getActiveSessionBackupVersion = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("9"); + const getSessionBackupPrivateKey = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(new Uint8Array([1])); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion, + getSessionBackupPrivateKey, + loadSessionBackupPrivateKeyFromSecretStorage, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const backup = await client.getRoomKeyBackupStatus(); + expect(backup).toMatchObject({ + serverVersion: "9", + activeVersion: "9", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: true, + keyLoadError: null, + }); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + }); + + it("reloads backup keys from secret storage when the cached key mismatches the active backup", async () => { + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const isKeyBackupTrusted = vi + .fn() + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: false, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => "49262"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage, + checkKeyBackupAndEnable, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "49262", + })), + isKeyBackupTrusted, + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const backup = await client.getRoomKeyBackupStatus(); + expect(backup).toMatchObject({ + serverVersion: "49262", + activeVersion: "49262", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: true, + keyLoadError: null, + }); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + }); + + it("reports why backup key loading failed during status checks", async () => { + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => { + throw new Error("secret storage key is not available"); + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => null), + getSessionBackupPrivateKey: vi.fn(async () => null), + loadSessionBackupPrivateKeyFromSecretStorage, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const backup = await client.getRoomKeyBackupStatus(); + expect(backup.keyLoadAttempted).toBe(true); + expect(backup.keyLoadError).toContain("secret storage key is not available"); + expect(backup.decryptionKeyCached).toBe(false); + }); + + it("restores room keys from backup after loading key from secret storage", async () => { + const getActiveSessionBackupVersion = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("9") + .mockResolvedValue("9"); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const restoreKeyBackup = vi.fn(async () => ({ imported: 4, total: 10 })); + const crypto = { + on: vi.fn(), + getActiveSessionBackupVersion, + loadSessionBackupPrivateKeyFromSecretStorage, + checkKeyBackupAndEnable, + restoreKeyBackup, + getSessionBackupPrivateKey: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + }; + matrixJsClient.getCrypto = vi.fn(() => crypto); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" }); + + const result = await client.restoreRoomKeyBackup(); + expect(result.success).toBe(true); + expect(result.backupVersion).toBe("9"); + expect(result.imported).toBe(4); + expect(result.total).toBe(10); + expect(result.loadedFromSecretStorage).toBe(true); + expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + expect(restoreKeyBackup).toHaveBeenCalledTimes(1); + }); + + it("activates backup after loading the key from secret storage before restore", async () => { + const getActiveSessionBackupVersion = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("5256") + .mockResolvedValue("5256"); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const restoreKeyBackup = vi.fn(async () => ({ imported: 0, total: 0 })); + const crypto = { + on: vi.fn(), + getActiveSessionBackupVersion, + getSessionBackupPrivateKey: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValue(new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage, + checkKeyBackupAndEnable, + restoreKeyBackup, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "5256", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + }; + matrixJsClient.getCrypto = vi.fn(() => crypto); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "5256" }); + + const result = await client.restoreRoomKeyBackup(); + expect(result.success).toBe(true); + expect(result.backupVersion).toBe("5256"); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + expect(restoreKeyBackup).toHaveBeenCalledTimes(1); + }); + + it("fails restore when backup key cannot be loaded on this device", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => null), + getSessionBackupPrivateKey: vi.fn(async () => null), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "3", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "3" }); + + const result = await client.restoreRoomKeyBackup(); + expect(result.success).toBe(false); + expect(result.error).toContain("backup decryption key could not be loaded from secret storage"); + expect(result.backupVersion).toBe("3"); + expect(result.backup.matchesDecryptionKey).toBe(false); + }); + + it("reloads the matching backup key before restore when the cached key mismatches", async () => { + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const restoreKeyBackup = vi.fn(async () => ({ imported: 6, total: 9 })); + const isKeyBackupTrusted = vi + .fn() + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: false, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => "49262"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + loadSessionBackupPrivateKeyFromSecretStorage, + checkKeyBackupAndEnable: vi.fn(async () => {}), + restoreKeyBackup, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "49262", + })), + isKeyBackupTrusted, + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const result = await client.restoreRoomKeyBackup(); + + expect(result.success).toBe(true); + expect(result.backupVersion).toBe("49262"); + expect(result.imported).toBe(6); + expect(result.total).toBe(9); + expect(result.loadedFromSecretStorage).toBe(true); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(restoreKeyBackup).toHaveBeenCalledTimes(1); + }); + + it("resets the current room-key backup and creates a fresh trusted version", async () => { + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage, + checkKeyBackupAndEnable, + getActiveSessionBackupVersion: vi.fn(async () => "21869"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "21869", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && String(endpoint).includes("/room_keys/version")) { + return { version: "21868" }; + } + if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) { + return {}; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup(); + + expect(result.success).toBe(true); + expect(result.previousVersion).toBe("21868"); + expect(result.deletedVersion).toBe("21868"); + expect(result.createdVersion).toBe("21869"); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ setupNewKeyBackup: true }), + ); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1); + }); + + it("reloads the new backup decryption key after reset when the old cached key mismatches", async () => { + const checkKeyBackupAndEnable = vi.fn(async () => {}); + const bootstrapSecretStorage = vi.fn(async () => {}); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const isKeyBackupTrusted = vi + .fn() + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: false, + }) + .mockResolvedValueOnce({ + trusted: true, + matchesDecryptionKey: true, + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage, + checkKeyBackupAndEnable, + loadSessionBackupPrivateKeyFromSecretStorage, + getActiveSessionBackupVersion: vi.fn(async () => "49262"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "49262", + })), + isKeyBackupTrusted, + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && String(endpoint).includes("/room_keys/version")) { + return { version: "22245" }; + } + if (method === "DELETE" && String(endpoint).includes("/room_keys/version/22245")) { + return {}; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup(); + + expect(result.success).toBe(true); + expect(result.createdVersion).toBe("49262"); + expect(result.backup.matchesDecryptionKey).toBe(true); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(2); + }); + + it("fails reset when the recreated backup still does not match the local decryption key", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapSecretStorage: vi.fn(async () => {}), + checkKeyBackupAndEnable: vi.fn(async () => {}), + getActiveSessionBackupVersion: vi.fn(async () => "21868"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "21868", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => { + if (method === "GET" && String(endpoint).includes("/room_keys/version")) { + return { version: "21868" }; + } + if (method === "DELETE" && String(endpoint).includes("/room_keys/version/21868")) { + return {}; + } + return {}; + }); + + const result = await client.resetRoomKeyBackup(); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not have the matching backup decryption key"); + expect(result.createdVersion).toBe("21868"); + expect(result.backup.matchesDecryptionKey).toBe(false); + }); + + it("reports bootstrap failure when cross-signing keys are not published", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(false); + expect(result.error).toContain( + "Cross-signing bootstrap finished but server keys are still not published", + ); + expect(matrixJsClient.startClient).toHaveBeenCalledTimes(1); + }); + + it("reports bootstrap success when own device is verified and keys are published", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + getActiveSessionBackupVersion: vi.fn(async () => "9"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "9" }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(true); + expect(result.verification.verified).toBe(true); + expect(result.crossSigning.published).toBe(true); + expect(result.cryptoBootstrap).not.toBeNull(); + }); + + it("reports bootstrap failure when the device is only locally trusted", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(false); + expect(result.verification.localVerified).toBe(true); + expect(result.verification.signedByOwner).toBe(false); + expect(result.error).toContain("not verified by its owner after bootstrap"); + }); + + it("creates a key backup during bootstrap when none exists on the server", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + getActiveSessionBackupVersion: vi.fn(async () => "7"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "7", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + let backupChecks = 0; + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + backupChecks += 1; + return backupChecks >= 2 ? { version: "7" } : {}; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("7"); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ setupNewKeyBackup: true }), + ); + }); + + it("does not recreate key backup during bootstrap when one already exists", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + getActiveSessionBackupVersion: vi.fn(async () => "9"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + return { version: "9" }; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("9"); + const bootstrapSecretStorageCalls = bootstrapSecretStorage.mock.calls as Array< + [{ setupNewKeyBackup?: boolean }?] + >; + expect(bootstrapSecretStorageCalls.some((call) => Boolean(call[0]?.setupNewKeyBackup))).toBe( + false, + ); + }); + + it("does not report bootstrap errors when final verification state is healthy", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 90))); + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + getActiveSessionBackupVersion: vi.fn(async () => "12"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "12", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "12" }); + + const result = await client.bootstrapOwnDeviceVerification({ + recoveryKey: encoded as string, + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts new file mode 100644 index 00000000000..94ac1990096 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk.ts @@ -0,0 +1,1515 @@ +// Polyfill IndexedDB for WASM crypto in Node.js +import "fake-indexeddb/auto"; +import { EventEmitter } from "node:events"; +import { + ClientEvent, + MatrixEventEvent, + createClient as createMatrixJsClient, + type MatrixClient as MatrixJsClient, + type MatrixEvent, +} from "matrix-js-sdk"; +import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; +import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js"; +import { FileBackedMatrixSyncStore } from "./client/file-sync-store.js"; +import { createMatrixJsSdkClientLogger } from "./client/logging.js"; +import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; +import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js"; +import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js"; +import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js"; +import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js"; +import { MatrixAuthedHttpClient } from "./sdk/http-client.js"; +import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js"; +import { ConsoleLogger, LogService, noop } from "./sdk/logger.js"; +import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js"; +import { type HttpMethod, type QueryParams } from "./sdk/transport.js"; +import type { + MatrixClientEventMap, + MatrixCryptoBootstrapApi, + MatrixDeviceVerificationStatusLike, + MatrixRelationsPage, + MatrixRawEvent, + MessageEventContent, +} from "./sdk/types.js"; +import { + MatrixVerificationManager, + type MatrixVerificationSummary, +} from "./sdk/verification-manager.js"; +import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js"; + +export { ConsoleLogger, LogService }; +export type { + DimensionalFileInfo, + FileWithThumbnailInfo, + TimedFileInfo, + VideoFileInfo, +} from "./sdk/types.js"; +export type { + EncryptedFile, + LocationMessageEventContent, + MatrixRawEvent, + MessageEventContent, + TextualMessageEventContent, +} from "./sdk/types.js"; + +export type MatrixOwnDeviceVerificationStatus = { + encryptionEnabled: boolean; + userId: string | null; + deviceId: string | null; + // "verified" is intentionally strict: other Matrix clients should trust messages + // from this device without showing "not verified by its owner" warnings. + verified: boolean; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + recoveryKeyStored: boolean; + recoveryKeyCreatedAt: string | null; + recoveryKeyId: string | null; + backupVersion: string | null; + backup: MatrixRoomKeyBackupStatus; +}; + +export type MatrixRoomKeyBackupStatus = { + serverVersion: string | null; + activeVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + decryptionKeyCached: boolean | null; + keyLoadAttempted: boolean; + keyLoadError: string | null; +}; + +export type MatrixRoomKeyBackupRestoreResult = { + success: boolean; + error?: string; + backupVersion: string | null; + imported: number; + total: number; + loadedFromSecretStorage: boolean; + restoredAt?: string; + backup: MatrixRoomKeyBackupStatus; +}; + +export type MatrixRoomKeyBackupResetResult = { + success: boolean; + error?: string; + previousVersion: string | null; + deletedVersion: string | null; + createdVersion: string | null; + resetAt?: string; + backup: MatrixRoomKeyBackupStatus; +}; + +export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & { + success: boolean; + verifiedAt?: string; + error?: string; +}; + +export type MatrixOwnCrossSigningPublicationStatus = { + userId: string | null; + masterKeyPublished: boolean; + selfSigningKeyPublished: boolean; + userSigningKeyPublished: boolean; + published: boolean; +}; + +export type MatrixVerificationBootstrapResult = { + success: boolean; + error?: string; + verification: MatrixOwnDeviceVerificationStatus; + crossSigning: MatrixOwnCrossSigningPublicationStatus; + pendingVerifications: number; + cryptoBootstrap: MatrixCryptoBootstrapResult | null; +}; + +export type MatrixOwnDeviceInfo = { + deviceId: string; + displayName: string | null; + lastSeenIp: string | null; + lastSeenTs: number | null; + current: boolean; +}; + +export type MatrixOwnDeviceDeleteResult = { + currentDeviceId: string | null; + deletedDeviceIds: string[]; + remainingDevices: MatrixOwnDeviceInfo[]; +}; + +function normalizeOptionalString(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +function isMatrixNotFoundError(err: unknown): boolean { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + if (errObj?.statusCode === 404 || errObj?.body?.errcode === "M_NOT_FOUND") { + return true; + } + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return ( + message.includes("m_not_found") || message.includes("[404]") || message.includes("not found") + ); +} + +function isUnsupportedAuthenticatedMediaEndpointError(err: unknown): boolean { + const statusCode = (err as { statusCode?: number })?.statusCode; + if (statusCode === 404 || statusCode === 405 || statusCode === 501) { + return true; + } + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return ( + message.includes("m_unrecognized") || + message.includes("unrecognized request") || + message.includes("method not allowed") || + message.includes("not implemented") + ); +} + +export class MatrixClient { + private readonly client: MatrixJsClient; + private readonly emitter = new EventEmitter(); + private readonly httpClient: MatrixAuthedHttpClient; + private readonly localTimeoutMs: number; + private readonly initialSyncLimit?: number; + private readonly encryptionEnabled: boolean; + private readonly password?: string; + private readonly syncStore?: FileBackedMatrixSyncStore; + private readonly idbSnapshotPath?: string; + private readonly cryptoDatabasePrefix?: string; + private bridgeRegistered = false; + private started = false; + private cryptoBootstrapped = false; + private selfUserId: string | null; + private readonly dmRoomIds = new Set(); + private cryptoInitialized = false; + private readonly decryptBridge: MatrixDecryptBridge; + private readonly verificationManager = new MatrixVerificationManager(); + private readonly sendQueue = new KeyedAsyncQueue(); + private readonly recoveryKeyStore: MatrixRecoveryKeyStore; + private readonly cryptoBootstrapper: MatrixCryptoBootstrapper; + private readonly autoBootstrapCrypto: boolean; + private stopPersistPromise: Promise | null = null; + + readonly dms = { + update: async (): Promise => { + await this.refreshDmCache(); + }, + isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId), + }; + + crypto?: MatrixCryptoFacade; + + constructor( + homeserver: string, + accessToken: string, + _storage?: unknown, + _cryptoStorage?: unknown, + opts: { + userId?: string; + password?: string; + deviceId?: string; + localTimeoutMs?: number; + encryption?: boolean; + initialSyncLimit?: number; + storagePath?: string; + recoveryKeyPath?: string; + idbSnapshotPath?: string; + cryptoDatabasePrefix?: string; + autoBootstrapCrypto?: boolean; + } = {}, + ) { + this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken); + this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000); + this.initialSyncLimit = opts.initialSyncLimit; + this.encryptionEnabled = opts.encryption === true; + this.password = opts.password; + this.syncStore = opts.storagePath ? new FileBackedMatrixSyncStore(opts.storagePath) : undefined; + this.idbSnapshotPath = opts.idbSnapshotPath; + this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; + this.selfUserId = opts.userId?.trim() || null; + this.autoBootstrapCrypto = opts.autoBootstrapCrypto !== false; + this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath); + const cryptoCallbacks = this.encryptionEnabled + ? this.recoveryKeyStore.buildCryptoCallbacks() + : undefined; + this.client = createMatrixJsClient({ + baseUrl: homeserver, + accessToken, + userId: opts.userId, + deviceId: opts.deviceId, + logger: createMatrixJsSdkClientLogger("MatrixClient"), + localTimeoutMs: this.localTimeoutMs, + store: this.syncStore, + cryptoCallbacks: cryptoCallbacks as never, + verificationMethods: [ + VerificationMethod.Sas, + VerificationMethod.ShowQrCode, + VerificationMethod.ScanQrCode, + VerificationMethod.Reciprocate, + ], + }); + this.decryptBridge = new MatrixDecryptBridge({ + client: this.client, + toRaw: (event) => matrixEventToRaw(event), + emitDecryptedEvent: (roomId, event) => { + this.emitter.emit("room.decrypted_event", roomId, event); + }, + emitMessage: (roomId, event) => { + this.emitter.emit("room.message", roomId, event); + }, + emitFailedDecryption: (roomId, event, error) => { + this.emitter.emit("room.failed_decryption", roomId, event, error); + }, + }); + this.cryptoBootstrapper = new MatrixCryptoBootstrapper({ + getUserId: () => this.getUserId(), + getPassword: () => opts.password, + getDeviceId: () => this.client.getDeviceId(), + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + decryptBridge: this.decryptBridge, + }); + this.verificationManager.onSummaryChanged((summary: MatrixVerificationSummary) => { + this.emitter.emit("verification.summary", summary); + }); + + if (this.encryptionEnabled) { + this.crypto = createMatrixCryptoFacade({ + client: this.client, + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + getRoomStateEvent: (roomId, eventType, stateKey = "") => + this.getRoomStateEvent(roomId, eventType, stateKey), + downloadContent: (mxcUrl) => this.downloadContent(mxcUrl), + }); + } + } + + on( + eventName: TEvent, + listener: (...args: MatrixClientEventMap[TEvent]) => void, + ): this; + on(eventName: string, listener: (...args: unknown[]) => void): this; + on(eventName: string, listener: (...args: unknown[]) => void): this { + this.emitter.on(eventName, listener as (...args: unknown[]) => void); + return this; + } + + off( + eventName: TEvent, + listener: (...args: MatrixClientEventMap[TEvent]) => void, + ): this; + off(eventName: string, listener: (...args: unknown[]) => void): this; + off(eventName: string, listener: (...args: unknown[]) => void): this { + this.emitter.off(eventName, listener as (...args: unknown[]) => void); + return this; + } + + private idbPersistTimer: ReturnType | null = null; + + async start(): Promise { + await this.startSyncSession({ bootstrapCrypto: true }); + } + + private async startSyncSession(opts: { bootstrapCrypto: boolean }): Promise { + if (this.started) { + return; + } + + this.registerBridge(); + await this.initializeCryptoIfNeeded(); + + await this.client.startClient({ + initialSyncLimit: this.initialSyncLimit, + }); + if (opts.bootstrapCrypto && this.autoBootstrapCrypto) { + await this.bootstrapCryptoIfNeeded(); + } + this.started = true; + this.emitOutstandingInviteEvents(); + await this.refreshDmCache().catch(noop); + } + + async prepareForOneOff(): Promise { + if (!this.encryptionEnabled) { + return; + } + await this.initializeCryptoIfNeeded(); + if (!this.crypto) { + return; + } + try { + const joinedRooms = await this.getJoinedRooms(); + await this.crypto.prepare(joinedRooms); + } catch { + // One-off commands should continue even if crypto room prep is incomplete. + } + } + + hasPersistedSyncState(): boolean { + return this.syncStore?.hasSavedSync() === true; + } + + private async ensureStartedForCryptoControlPlane(): Promise { + if (this.started) { + return; + } + await this.startSyncSession({ bootstrapCrypto: false }); + } + + stop(): void { + if (this.idbPersistTimer) { + clearInterval(this.idbPersistTimer); + this.idbPersistTimer = null; + } + this.decryptBridge.stop(); + // Final persist on shutdown + this.stopPersistPromise = Promise.all([ + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop), + this.syncStore?.flush().catch(noop), + ]).then(() => undefined); + this.client.stopClient(); + this.started = false; + } + + async stopAndPersist(): Promise { + this.stop(); + await this.stopPersistPromise; + } + + private async bootstrapCryptoIfNeeded(): Promise { + if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) { + return; + } + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return; + } + const initial = await this.cryptoBootstrapper.bootstrap(crypto, { + allowAutomaticCrossSigningReset: false, + }); + if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) { + const status = await this.getOwnDeviceVerificationStatus(); + if (status.signedByOwner) { + LogService.warn( + "MatrixClientLite", + "Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.", + ); + } else if (this.password?.trim()) { + try { + const repaired = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: true, + strict: true, + }); + if (repaired.crossSigningPublished && repaired.ownDeviceVerified !== false) { + LogService.info( + "MatrixClientLite", + "Cross-signing/bootstrap recovered after forced reset", + ); + } + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Failed to recover cross-signing/bootstrap with forced reset:", + err, + ); + } + } else { + LogService.warn( + "MatrixClientLite", + "Cross-signing/bootstrap incomplete and no password is configured for UIA fallback", + ); + } + } + this.cryptoBootstrapped = true; + } + + private async initializeCryptoIfNeeded(): Promise { + if (!this.encryptionEnabled || this.cryptoInitialized) { + return; + } + + // Restore persisted IndexedDB crypto store before initializing WASM crypto. + await restoreIdbFromDisk(this.idbSnapshotPath); + + try { + await this.client.initRustCrypto({ + cryptoDatabasePrefix: this.cryptoDatabasePrefix, + }); + this.cryptoInitialized = true; + + // Persist the crypto store after successful init (captures fresh keys on first run). + await persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }); + + // Periodically persist to capture new Olm sessions and room keys. + this.idbPersistTimer = setInterval(() => { + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); + }, 60_000); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err); + } + } + + async getUserId(): Promise { + const fromClient = this.client.getUserId(); + if (fromClient) { + this.selfUserId = fromClient; + return fromClient; + } + if (this.selfUserId) { + return this.selfUserId; + } + const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { + user_id?: string; + }; + const resolved = whoami.user_id?.trim(); + if (!resolved) { + throw new Error("Matrix whoami did not return user_id"); + } + this.selfUserId = resolved; + return resolved; + } + + async getJoinedRooms(): Promise { + const joined = await this.client.getJoinedRooms(); + return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : []; + } + + async getJoinedRoomMembers(roomId: string): Promise { + const members = await this.client.getJoinedRoomMembers(roomId); + const joined = members?.joined; + if (!joined || typeof joined !== "object") { + return []; + } + return Object.keys(joined); + } + + async getRoomStateEvent( + roomId: string, + eventType: string, + stateKey = "", + ): Promise> { + const state = await this.client.getStateEvent(roomId, eventType, stateKey); + return (state ?? {}) as Record; + } + + async getAccountData(eventType: string): Promise | undefined> { + const event = this.client.getAccountData(eventType as never); + return (event?.getContent() as Record | undefined) ?? undefined; + } + + async setAccountData(eventType: string, content: Record): Promise { + await this.client.setAccountData(eventType as never, content as never); + await this.refreshDmCache().catch(noop); + } + + async resolveRoom(aliasOrRoomId: string): Promise { + if (aliasOrRoomId.startsWith("!")) { + return aliasOrRoomId; + } + if (!aliasOrRoomId.startsWith("#")) { + return aliasOrRoomId; + } + try { + const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId); + return resolved.room_id ?? null; + } catch { + return null; + } + } + + async createDirectRoom( + remoteUserId: string, + opts: { encrypted?: boolean } = {}, + ): Promise { + const initialState = opts.encrypted + ? [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ] + : undefined; + const result = await this.client.createRoom({ + invite: [remoteUserId], + is_direct: true, + preset: "trusted_private_chat", + initial_state: initialState, + }); + return result.room_id; + } + + async sendMessage(roomId: string, content: MessageEventContent): Promise { + return await this.runSerializedRoomSend(roomId, async () => { + const sent = await this.client.sendMessage(roomId, content as never); + return sent.event_id; + }); + } + + async sendEvent( + roomId: string, + eventType: string, + content: Record, + ): Promise { + return await this.runSerializedRoomSend(roomId, async () => { + const sent = await this.client.sendEvent(roomId, eventType as never, content as never); + return sent.event_id; + }); + } + + // Keep outbound room events ordered when multiple plugin paths emit + // messages/reactions/polls into the same Matrix room concurrently. + private async runSerializedRoomSend(roomId: string, task: () => Promise): Promise { + return await this.sendQueue.enqueue(roomId, task); + } + + async sendStateEvent( + roomId: string, + eventType: string, + stateKey: string, + content: Record, + ): Promise { + const sent = await this.client.sendStateEvent( + roomId, + eventType as never, + content as never, + stateKey, + ); + return sent.event_id; + } + + async redactEvent(roomId: string, eventId: string, reason?: string): Promise { + const sent = await this.client.redactEvent( + roomId, + eventId, + undefined, + reason?.trim() ? { reason } : undefined, + ); + return sent.event_id; + } + + async doRequest( + method: HttpMethod, + endpoint: string, + qs?: QueryParams, + body?: unknown, + opts?: { allowAbsoluteEndpoint?: boolean }, + ): Promise { + return await this.httpClient.requestJson({ + method, + endpoint, + qs, + body, + timeoutMs: this.localTimeoutMs, + allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint, + }); + } + + async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> { + return await this.client.getProfileInfo(userId); + } + + async setDisplayName(displayName: string): Promise { + await this.client.setDisplayName(displayName); + } + + async setAvatarUrl(avatarUrl: string): Promise { + await this.client.setAvatarUrl(avatarUrl); + } + + async joinRoom(roomId: string): Promise { + await this.client.joinRoom(roomId); + } + + mxcToHttp(mxcUrl: string): string | null { + return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true); + } + + async downloadContent( + mxcUrl: string, + opts: { + allowRemote?: boolean; + maxBytes?: number; + readIdleTimeoutMs?: number; + } = {}, + ): Promise { + const parsed = parseMxc(mxcUrl); + if (!parsed) { + throw new Error(`Invalid Matrix content URI: ${mxcUrl}`); + } + const encodedServer = encodeURIComponent(parsed.server); + const encodedMediaId = encodeURIComponent(parsed.mediaId); + const request = async (endpoint: string): Promise => + await this.httpClient.requestRaw({ + method: "GET", + endpoint, + qs: { allow_remote: opts.allowRemote ?? true }, + timeoutMs: this.localTimeoutMs, + maxBytes: opts.maxBytes, + readIdleTimeoutMs: opts.readIdleTimeoutMs, + }); + + const authenticatedEndpoint = `/_matrix/client/v1/media/download/${encodedServer}/${encodedMediaId}`; + try { + return await request(authenticatedEndpoint); + } catch (err) { + if (!isUnsupportedAuthenticatedMediaEndpointError(err)) { + throw err; + } + } + + const legacyEndpoint = `/_matrix/media/v3/download/${encodedServer}/${encodedMediaId}`; + return await request(legacyEndpoint); + } + + async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise { + const uploaded = await this.client.uploadContent(new Uint8Array(file), { + type: contentType || "application/octet-stream", + name: filename, + includeFilename: Boolean(filename), + }); + return uploaded.content_uri; + } + + async getEvent(roomId: string, eventId: string): Promise> { + const rawEvent = (await this.client.fetchRoomEvent(roomId, eventId)) as Record; + if (rawEvent.type !== "m.room.encrypted") { + return rawEvent; + } + + const mapper = this.client.getEventMapper(); + const event = mapper(rawEvent); + let decryptedEvent: MatrixEvent | undefined; + const onDecrypted = (candidate: MatrixEvent) => { + decryptedEvent = candidate; + }; + event.once(MatrixEventEvent.Decrypted, onDecrypted); + try { + await this.client.decryptEventIfNeeded(event); + } finally { + event.off(MatrixEventEvent.Decrypted, onDecrypted); + } + return matrixEventToRaw(decryptedEvent ?? event); + } + + async getRelations( + roomId: string, + eventId: string, + relationType: string | null, + eventType?: string | null, + opts: { + from?: string; + } = {}, + ): Promise { + const result = await this.client.relations(roomId, eventId, relationType, eventType, opts); + return { + originalEvent: result.originalEvent ? matrixEventToRaw(result.originalEvent) : null, + events: result.events.map((event) => matrixEventToRaw(event)), + nextBatch: result.nextBatch ?? null, + prevBatch: result.prevBatch ?? null, + }; + } + + async hydrateEvents( + roomId: string, + events: Array>, + ): Promise { + if (events.length === 0) { + return []; + } + + const mapper = this.client.getEventMapper(); + const mappedEvents = events.map((event) => + mapper({ + room_id: roomId, + ...event, + }), + ); + await Promise.all(mappedEvents.map((event) => this.client.decryptEventIfNeeded(event))); + return mappedEvents.map((event) => matrixEventToRaw(event)); + } + + async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise { + await this.client.sendTyping(roomId, typing, timeoutMs); + } + + async sendReadReceipt(roomId: string, eventId: string): Promise { + await this.httpClient.requestJson({ + method: "POST", + endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent( + eventId, + )}`, + body: {}, + timeoutMs: this.localTimeoutMs, + }); + } + + async getRoomKeyBackupStatus(): Promise { + if (!this.encryptionEnabled) { + return { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + const serverVersionFallback = await this.resolveRoomKeyBackupVersion(); + if (!crypto) { + return { + serverVersion: serverVersionFallback, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }; + } + + let { activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto); + let { serverVersion, trusted, matchesDecryptionKey } = + await this.resolveRoomKeyBackupTrustState(crypto, serverVersionFallback); + const shouldLoadBackupKey = + Boolean(serverVersion) && (decryptionKeyCached === false || matchesDecryptionKey === false); + const shouldActivateBackup = Boolean(serverVersion) && !activeVersion; + let keyLoadAttempted = false; + let keyLoadError: string | null = null; + if (serverVersion && (shouldLoadBackupKey || shouldActivateBackup)) { + if (shouldLoadBackupKey) { + if ( + typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage === + "function" /* pragma: allowlist secret */ + ) { + keyLoadAttempted = true; + try { + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); // pragma: allowlist secret + } catch (err) { + keyLoadError = err instanceof Error ? err.message : String(err); + } + } else { + keyLoadError = + "Matrix crypto backend does not support loading backup keys from secret storage"; + } + } + if (!keyLoadError) { + await this.enableTrustedRoomKeyBackupIfPossible(crypto); + } + ({ activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto)); + ({ serverVersion, trusted, matchesDecryptionKey } = await this.resolveRoomKeyBackupTrustState( + crypto, + serverVersion, + )); + } + + return { + serverVersion, + activeVersion, + trusted, + matchesDecryptionKey, + decryptionKeyCached, + keyLoadAttempted, + keyLoadError, + }; + } + + async getOwnDeviceVerificationStatus(): Promise { + const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary(); + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + const deviceId = this.client.getDeviceId()?.trim() || null; + const backup = await this.getRoomKeyBackupStatus(); + + if (!this.encryptionEnabled) { + return { + encryptionEnabled: false, + userId, + deviceId, + verified: false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: backup.serverVersion, + backup, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + let deviceStatus: MatrixDeviceVerificationStatusLike | null = null; + if (crypto && userId && deviceId && typeof crypto.getDeviceVerificationStatus === "function") { + deviceStatus = await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null); + } + + return { + encryptionEnabled: true, + userId, + deviceId, + verified: isMatrixDeviceOwnerVerified(deviceStatus), + localVerified: deviceStatus?.localVerified === true, + crossSigningVerified: deviceStatus?.crossSigningVerified === true, + signedByOwner: deviceStatus?.signedByOwner === true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: backup.serverVersion, + backup, + }; + } + + async verifyWithRecoveryKey( + rawRecoveryKey: string, + ): Promise { + const fail = async (error: string): Promise => ({ + success: false, + error, + ...(await this.getOwnDeviceVerificationStatus()), + }); + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + await this.ensureStartedForCryptoControlPlane(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + const trimmedRecoveryKey = rawRecoveryKey.trim(); + if (!trimmedRecoveryKey) { + return await fail("Matrix recovery key is required"); + } + + try { + this.recoveryKeyStore.stageEncodedRecoveryKey({ + encodedPrivateKey: trimmedRecoveryKey, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } catch (err) { + return await fail(err instanceof Error ? err.message : String(err)); + } + + try { + await this.cryptoBootstrapper.bootstrap(crypto, { + allowAutomaticCrossSigningReset: false, + }); + await this.enableTrustedRoomKeyBackupIfPossible(crypto); + const status = await this.getOwnDeviceVerificationStatus(); + if (!status.verified) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return { + success: false, + error: + "Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.", + ...status, + }; + } + const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, { + requireServerBackup: false, + }); + if (backupError) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return { + success: false, + error: backupError, + ...status, + }; + } + + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + const committedStatus = await this.getOwnDeviceVerificationStatus(); + return { + success: true, + verifiedAt: new Date().toISOString(), + ...committedStatus, + }; + } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail(err instanceof Error ? err.message : String(err)); + } + } + + async restoreRoomKeyBackup( + params: { + recoveryKey?: string; + } = {}, + ): Promise { + let loadedFromSecretStorage = false; + const fail = async (error: string): Promise => { + const backup = await this.getRoomKeyBackupStatus(); + return { + success: false, + error, + backupVersion: backup.serverVersion, + imported: 0, + total: 0, + loadedFromSecretStorage, + backup, + }; + }; + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + await this.ensureStartedForCryptoControlPlane(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + try { + const rawRecoveryKey = params.recoveryKey?.trim(); + if (rawRecoveryKey) { + this.recoveryKeyStore.stageEncodedRecoveryKey({ + encodedPrivateKey: rawRecoveryKey, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } + + const backup = await this.getRoomKeyBackupStatus(); + loadedFromSecretStorage = backup.keyLoadAttempted && !backup.keyLoadError; + const backupError = resolveMatrixRoomKeyBackupReadinessError(backup, { + requireServerBackup: true, + }); + if (backupError) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail(backupError); + } + if (typeof crypto.restoreKeyBackup !== "function") { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail("Matrix crypto backend does not support full key backup restore"); + } + + const restore = await crypto.restoreKeyBackup(); + if (rawRecoveryKey) { + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } + const finalBackup = await this.getRoomKeyBackupStatus(); + return { + success: true, + backupVersion: backup.serverVersion, + imported: typeof restore.imported === "number" ? restore.imported : 0, + total: typeof restore.total === "number" ? restore.total : 0, + loadedFromSecretStorage, + restoredAt: new Date().toISOString(), + backup: finalBackup, + }; + } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + return await fail(err instanceof Error ? err.message : String(err)); + } + } + + async resetRoomKeyBackup(): Promise { + let previousVersion: string | null = null; + let deletedVersion: string | null = null; + const fail = async (error: string): Promise => { + const backup = await this.getRoomKeyBackupStatus(); + return { + success: false, + error, + previousVersion, + deletedVersion, + createdVersion: backup.serverVersion, + backup, + }; + }; + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + await this.ensureStartedForCryptoControlPlane(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + previousVersion = await this.resolveRoomKeyBackupVersion(); + + try { + if (previousVersion) { + try { + await this.doRequest( + "DELETE", + `/_matrix/client/v3/room_keys/version/${encodeURIComponent(previousVersion)}`, + ); + } catch (err) { + if (!isMatrixNotFoundError(err)) { + throw err; + } + } + deletedVersion = previousVersion; + } + + await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + setupNewKeyBackup: true, + }); + await this.enableTrustedRoomKeyBackupIfPossible(crypto); + + const backup = await this.getRoomKeyBackupStatus(); + const createdVersion = backup.serverVersion; + if (!createdVersion) { + return await fail("Matrix room key backup is still missing after reset."); + } + if (backup.activeVersion !== createdVersion) { + return await fail( + "Matrix room key backup was recreated on the server but is not active on this device.", + ); + } + if (backup.decryptionKeyCached === false) { + return await fail( + "Matrix room key backup was recreated but its decryption key is not cached on this device.", + ); + } + if (backup.matchesDecryptionKey === false) { + return await fail( + "Matrix room key backup was recreated but this device does not have the matching backup decryption key.", + ); + } + if (backup.trusted === false) { + return await fail( + "Matrix room key backup was recreated but is not trusted on this device.", + ); + } + + return { + success: true, + previousVersion, + deletedVersion, + createdVersion, + resetAt: new Date().toISOString(), + backup, + }; + } catch (err) { + return await fail(err instanceof Error ? err.message : String(err)); + } + } + + async getOwnCrossSigningPublicationStatus(): Promise { + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + if (!userId) { + return { + userId: null, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + + try { + const response = (await this.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, { + device_keys: { [userId]: [] as string[] }, + })) as { + master_keys?: Record; + self_signing_keys?: Record; + user_signing_keys?: Record; + }; + const masterKeyPublished = Boolean(response.master_keys?.[userId]); + const selfSigningKeyPublished = Boolean(response.self_signing_keys?.[userId]); + const userSigningKeyPublished = Boolean(response.user_signing_keys?.[userId]); + return { + userId, + masterKeyPublished, + selfSigningKeyPublished, + userSigningKeyPublished, + published: masterKeyPublished && selfSigningKeyPublished && userSigningKeyPublished, + }; + } catch { + return { + userId, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + } + + async bootstrapOwnDeviceVerification(params?: { + recoveryKey?: string; + forceResetCrossSigning?: boolean; + }): Promise { + const pendingVerifications = async (): Promise => + this.crypto ? (await this.crypto.listVerifications()).length : 0; + if (!this.encryptionEnabled) { + return { + success: false, + error: "Matrix encryption is disabled for this client", + verification: await this.getOwnDeviceVerificationStatus(), + crossSigning: await this.getOwnCrossSigningPublicationStatus(), + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: null, + }; + } + + let bootstrapError: string | undefined; + let bootstrapSummary: MatrixCryptoBootstrapResult | null = null; + try { + await this.ensureStartedForCryptoControlPlane(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + throw new Error("Matrix crypto is not available (start client with encryption enabled)"); + } + + const rawRecoveryKey = params?.recoveryKey?.trim(); + if (rawRecoveryKey) { + this.recoveryKeyStore.stageEncodedRecoveryKey({ + encodedPrivateKey: rawRecoveryKey, + keyId: await this.resolveDefaultSecretStorageKeyId(crypto), + }); + } + + bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: params?.forceResetCrossSigning === true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + strict: true, + }); + await this.ensureRoomKeyBackupEnabled(crypto); + } catch (err) { + this.recoveryKeyStore.discardStagedRecoveryKey(); + bootstrapError = err instanceof Error ? err.message : String(err); + } + + const verification = await this.getOwnDeviceVerificationStatus(); + const crossSigning = await this.getOwnCrossSigningPublicationStatus(); + const verificationError = + verification.verified && crossSigning.published + ? null + : (bootstrapError ?? + "Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys"); + const backupError = + verificationError === null + ? resolveMatrixRoomKeyBackupReadinessError(verification.backup, { + requireServerBackup: true, + }) + : null; + const success = verificationError === null && backupError === null; + if (success) { + this.recoveryKeyStore.commitStagedRecoveryKey({ + keyId: await this.resolveDefaultSecretStorageKeyId( + this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined, + ), + }); + } else { + this.recoveryKeyStore.discardStagedRecoveryKey(); + } + const error = success ? undefined : (backupError ?? verificationError ?? undefined); + return { + success, + error, + verification: success ? await this.getOwnDeviceVerificationStatus() : verification, + crossSigning, + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: bootstrapSummary, + }; + } + + async listOwnDevices(): Promise { + const currentDeviceId = this.client.getDeviceId()?.trim() || null; + const devices = await this.client.getDevices(); + const entries = Array.isArray(devices?.devices) ? devices.devices : []; + return entries.map((device) => ({ + deviceId: device.device_id, + displayName: device.display_name?.trim() || null, + lastSeenIp: device.last_seen_ip?.trim() || null, + lastSeenTs: + typeof device.last_seen_ts === "number" && Number.isFinite(device.last_seen_ts) + ? device.last_seen_ts + : null, + current: currentDeviceId !== null && device.device_id === currentDeviceId, + })); + } + + async deleteOwnDevices(deviceIds: string[]): Promise { + const uniqueDeviceIds = [...new Set(deviceIds.map((value) => value.trim()).filter(Boolean))]; + const currentDeviceId = this.client.getDeviceId()?.trim() || null; + const protectedDeviceIds = uniqueDeviceIds.filter((deviceId) => deviceId === currentDeviceId); + if (protectedDeviceIds.length > 0) { + throw new Error(`Refusing to delete the current Matrix device: ${protectedDeviceIds[0]}`); + } + + const deleteWithAuth = async (authData?: Record): Promise => { + await this.client.deleteMultipleDevices(uniqueDeviceIds, authData as never); + }; + + if (uniqueDeviceIds.length > 0) { + try { + await deleteWithAuth(); + } catch (err) { + const session = + err && + typeof err === "object" && + "data" in err && + err.data && + typeof err.data === "object" && + "session" in err.data && + typeof err.data.session === "string" + ? err.data.session + : null; + const userId = await this.getUserId().catch(() => this.selfUserId); + if (!session || !userId || !this.password?.trim()) { + throw err; + } + await deleteWithAuth({ + type: "m.login.password", + session, + identifier: { type: "m.id.user", user: userId }, + password: this.password, + }); + } + } + + return { + currentDeviceId, + deletedDeviceIds: uniqueDeviceIds, + remainingDevices: await this.listOwnDevices(), + }; + } + + private async resolveActiveRoomKeyBackupVersion( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + if (typeof crypto.getActiveSessionBackupVersion !== "function") { + return null; + } + const version = await crypto.getActiveSessionBackupVersion().catch(() => null); + return normalizeOptionalString(version); + } + + private async resolveCachedRoomKeyBackupDecryptionKey( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + const getSessionBackupPrivateKey = crypto.getSessionBackupPrivateKey; // pragma: allowlist secret + if (typeof getSessionBackupPrivateKey !== "function") { + return null; + } + const key = await getSessionBackupPrivateKey.call(crypto).catch(() => null); // pragma: allowlist secret + return key ? key.length > 0 : false; + } + + private async resolveRoomKeyBackupLocalState( + crypto: MatrixCryptoBootstrapApi, + ): Promise<{ activeVersion: string | null; decryptionKeyCached: boolean | null }> { + const [activeVersion, decryptionKeyCached] = await Promise.all([ + this.resolveActiveRoomKeyBackupVersion(crypto), + this.resolveCachedRoomKeyBackupDecryptionKey(crypto), + ]); + return { activeVersion, decryptionKeyCached }; + } + + private async resolveRoomKeyBackupTrustState( + crypto: MatrixCryptoBootstrapApi, + fallbackVersion: string | null, + ): Promise<{ + serverVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + }> { + let serverVersion = fallbackVersion; + let trusted: boolean | null = null; + let matchesDecryptionKey: boolean | null = null; + if (typeof crypto.getKeyBackupInfo === "function") { + const info = await crypto.getKeyBackupInfo().catch(() => null); + serverVersion = normalizeOptionalString(info?.version) ?? serverVersion; + if (info && typeof crypto.isKeyBackupTrusted === "function") { + const trustInfo = await crypto.isKeyBackupTrusted(info).catch(() => null); + trusted = typeof trustInfo?.trusted === "boolean" ? trustInfo.trusted : null; + matchesDecryptionKey = + typeof trustInfo?.matchesDecryptionKey === "boolean" + ? trustInfo.matchesDecryptionKey + : null; + } + } + return { serverVersion, trusted, matchesDecryptionKey }; + } + + private async resolveDefaultSecretStorageKeyId( + crypto: MatrixCryptoBootstrapApi | undefined, + ): Promise { + const getSecretStorageStatus = crypto?.getSecretStorageStatus; // pragma: allowlist secret + if (typeof getSecretStorageStatus !== "function") { + return undefined; + } + const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret + return status?.defaultKeyId; + } + + private async resolveRoomKeyBackupVersion(): Promise { + try { + const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as { + version?: string; + }; + return normalizeOptionalString(response.version); + } catch { + return null; + } + } + + private async enableTrustedRoomKeyBackupIfPossible( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + if (typeof crypto.checkKeyBackupAndEnable !== "function") { + return; + } + await crypto.checkKeyBackupAndEnable(); + } + + private async ensureRoomKeyBackupEnabled(crypto: MatrixCryptoBootstrapApi): Promise { + const existingVersion = await this.resolveRoomKeyBackupVersion(); + if (existingVersion) { + return; + } + LogService.info( + "MatrixClientLite", + "No room key backup version found on server, creating one via secret storage bootstrap", + ); + await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + setupNewKeyBackup: true, + }); + const createdVersion = await this.resolveRoomKeyBackupVersion(); + if (!createdVersion) { + throw new Error("Matrix room key backup is still missing after bootstrap"); + } + LogService.info("MatrixClientLite", `Room key backup enabled (version ${createdVersion})`); + } + + private registerBridge(): void { + if (this.bridgeRegistered) { + return; + } + this.bridgeRegistered = true; + + this.client.on(ClientEvent.Event, (event: MatrixEvent) => { + const roomId = event.getRoomId(); + if (!roomId) { + return; + } + + const raw = matrixEventToRaw(event); + const isEncryptedEvent = raw.type === "m.room.encrypted"; + this.emitter.emit("room.event", roomId, raw); + if (isEncryptedEvent) { + this.emitter.emit("room.encrypted_event", roomId, raw); + } else { + if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) { + this.emitter.emit("room.message", roomId, raw); + } + } + + const stateKey = raw.state_key ?? ""; + const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; + const membership = + raw.type === "m.room.member" + ? (raw.content as { membership?: string }).membership + : undefined; + if (stateKey && selfUserId && stateKey === selfUserId) { + if (membership === "invite") { + this.emitter.emit("room.invite", roomId, raw); + } else if (membership === "join") { + this.emitter.emit("room.join", roomId, raw); + } + } + + if (isEncryptedEvent) { + this.decryptBridge.attachEncryptedEvent(event, roomId); + } + }); + + // Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events. + this.client.on(ClientEvent.Room, (room) => { + this.emitMembershipForRoom(room); + }); + } + + private emitMembershipForRoom(room: unknown): void { + const roomObj = room as { + roomId?: string; + getMyMembership?: () => string | null | undefined; + selfMembership?: string | null | undefined; + }; + const roomId = roomObj.roomId?.trim(); + if (!roomId) { + return; + } + const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined; + const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; + if (!selfUserId) { + return; + } + const raw: MatrixRawEvent = { + event_id: `$membership-${roomId}-${Date.now()}`, + type: "m.room.member", + sender: selfUserId, + state_key: selfUserId, + content: { membership }, + origin_server_ts: Date.now(), + unsigned: { age: 0 }, + }; + if (membership === "invite") { + this.emitter.emit("room.invite", roomId, raw); + return; + } + if (membership === "join") { + this.emitter.emit("room.join", roomId, raw); + } + } + + private emitOutstandingInviteEvents(): void { + const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms; + if (typeof listRooms !== "function") { + return; + } + const rooms = listRooms.call(this.client); + if (!Array.isArray(rooms)) { + return; + } + for (const room of rooms) { + this.emitMembershipForRoom(room); + } + } + + private async refreshDmCache(): Promise { + const direct = await this.getAccountData("m.direct"); + this.dmRoomIds.clear(); + if (!direct || typeof direct !== "object") { + return; + } + for (const value of Object.values(direct)) { + if (!Array.isArray(value)) { + continue; + } + for (const roomId of value) { + if (typeof roomId === "string" && roomId.trim()) { + this.dmRoomIds.add(roomId); + } + } + } + } +} diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts new file mode 100644 index 00000000000..7e8a3b537c7 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -0,0 +1,507 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js"; +import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; + +function createBootstrapperDeps() { + return { + getUserId: vi.fn(async () => "@bot:example.org"), + getPassword: vi.fn(() => "super-secret-password"), + getDeviceId: vi.fn(() => "DEVICE123"), + verificationManager: { + trackVerificationRequest: vi.fn(), + }, + recoveryKeyStore: { + bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}), + }, + decryptBridge: { + bindCryptoRetrySignals: vi.fn(), + }, + }; +} + +function createCryptoApi(overrides?: Partial): MatrixCryptoBootstrapApi { + return { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + ...overrides, + }; +} + +describe("MatrixCryptoBootstrapper", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: false, + }, + ); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2); + expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); + }); + + it("forces new cross-signing keys only when readiness check still fails", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + userHasCrossSigningKeys: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("does not auto-reset cross-signing when automatic reset is disabled", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto, { + allowAutomaticCrossSigningReset: false, + }); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1); + expect(bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("passes explicit secret-storage repair allowance only when requested", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto, { + strict: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + }); + + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: true, + }, + ); + }); + + it("recreates secret storage and retries cross-signing when explicit bootstrap hits a stale server key", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey")) + .mockResolvedValueOnce(undefined); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto, { + strict: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + allowAutomaticCrossSigningReset: false, + }); + + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: true, + forceNewSecretStorage: true, + }, + ); + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("recreates secret storage and retries cross-signing when explicit bootstrap hits bad MAC", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Error decrypting secret m.cross_signing.master: bad MAC")) + .mockResolvedValueOnce(undefined); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto, { + strict: true, + allowSecretStorageRecreateWithoutRecoveryKey: true, + allowAutomaticCrossSigningReset: false, + }); + + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + { + allowSecretStorageRecreateWithoutRecoveryKey: true, + forceNewSecretStorage: true, + }, + ); + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + }); + + it("fails in strict mode when cross-signing keys are still unpublished", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + bootstrapCrossSigning: vi.fn(async () => {}), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow( + "Cross-signing bootstrap finished but server keys are still not published", + ); + }); + + it("uses password UIA fallback when null and dummy auth fail", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const bootstrapCrossSigningCalls = bootstrapCrossSigning.mock.calls as Array< + [ + { + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }?, + ] + >; + const authUploadDeviceSigningKeys = + bootstrapCrossSigningCalls[0]?.[0]?.authUploadDeviceSigningKeys; + expect(authUploadDeviceSigningKeys).toBeTypeOf("function"); + + const seenAuthStages: Array | null> = []; + const result = await authUploadDeviceSigningKeys?.(async (authData) => { + seenAuthStages.push(authData); + if (authData === null) { + throw new Error("need auth"); + } + if (authData.type === "m.login.dummy") { + throw new Error("dummy rejected"); + } + if (authData.type === "m.login.password") { + return "ok"; + } + throw new Error("unexpected auth stage"); + }); + + expect(result).toBe("ok"); + expect(seenAuthStages).toEqual([ + null, + { type: "m.login.dummy" }, + { + type: "m.login.password", + identifier: { type: "m.id.user", user: "@bot:example.org" }, + password: "super-secret-password", // pragma: allowlist secret + }, + ]); + }); + + it("resets cross-signing when first bootstrap attempt throws", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("first attempt failed")) + .mockResolvedValueOnce(undefined); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("marks own device verified and cross-signs it when needed", async () => { + const deps = createBootstrapperDeps(); + const setDeviceVerified = vi.fn(async () => {}); + const crossSignDevice = vi.fn(async () => {}); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + })), + setDeviceVerified, + crossSignDevice, + isCrossSigningReady: vi.fn(async () => true), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); + expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); + }); + + it("does not treat local-only trust as sufficient for own-device bootstrap", async () => { + const deps = createBootstrapperDeps(); + const setDeviceVerified = vi.fn(async () => {}); + const crossSignDevice = vi.fn(async () => {}); + const getDeviceVerificationStatus = vi + .fn< + () => Promise<{ + isVerified: () => boolean; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + }> + >() + .mockResolvedValueOnce({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + }) + .mockResolvedValueOnce({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + }); + const crypto = createCryptoApi({ + getDeviceVerificationStatus, + setDeviceVerified, + crossSignDevice, + isCrossSigningReady: vi.fn(async () => true), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); + expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); + expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(2); + }); + + it("tracks incoming verification requests from other users", async () => { + const deps = createBootstrapperDeps(); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith( + verificationRequest, + ); + expect(verificationRequest.accept).not.toHaveBeenCalled(); + }); + + it("does not touch request state when tracking summary throws", async () => { + const deps = createBootstrapperDeps(); + deps.verificationManager.trackVerificationRequest = vi.fn(() => { + throw new Error("summary failure"); + }); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(verificationRequest.accept).not.toHaveBeenCalled(); + }); + + it("registers verification listeners only once across repeated bootstrap calls", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + await bootstrapper.bootstrap(crypto); + + expect(crypto.on).toHaveBeenCalledTimes(1); + expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts new file mode 100644 index 00000000000..4a1a03fa83b --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -0,0 +1,341 @@ +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; +import { LogService } from "./logger.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import { isRepairableSecretStorageAccessError } from "./recovery-key-store.js"; +import type { + MatrixAuthDict, + MatrixCryptoBootstrapApi, + MatrixRawEvent, + MatrixUiAuthCallback, +} from "./types.js"; +import type { + MatrixVerificationManager, + MatrixVerificationRequestLike, +} from "./verification-manager.js"; +import { isMatrixDeviceOwnerVerified } from "./verification-status.js"; + +export type MatrixCryptoBootstrapperDeps = { + getUserId: () => Promise; + getPassword?: () => string | undefined; + getDeviceId: () => string | null | undefined; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + decryptBridge: Pick, "bindCryptoRetrySignals">; +}; + +export type MatrixCryptoBootstrapOptions = { + forceResetCrossSigning?: boolean; + allowAutomaticCrossSigningReset?: boolean; + allowSecretStorageRecreateWithoutRecoveryKey?: boolean; + strict?: boolean; +}; + +export type MatrixCryptoBootstrapResult = { + crossSigningReady: boolean; + crossSigningPublished: boolean; + ownDeviceVerified: boolean | null; +}; + +export class MatrixCryptoBootstrapper { + private verificationHandlerRegistered = false; + + constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} + + async bootstrap( + crypto: MatrixCryptoBootstrapApi, + options: MatrixCryptoBootstrapOptions = {}, + ): Promise { + const strict = options.strict === true; + // Register verification listeners before expensive bootstrap work so incoming requests + // are not missed during startup. + this.registerVerificationRequestHandler(crypto); + await this.bootstrapSecretStorage(crypto, { + strict, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + }); + const crossSigning = await this.bootstrapCrossSigning(crypto, { + forceResetCrossSigning: options.forceResetCrossSigning === true, + allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + strict, + }); + await this.bootstrapSecretStorage(crypto, { + strict, + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey === true, + }); + const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict); + return { + crossSigningReady: crossSigning.ready, + crossSigningPublished: crossSigning.published, + ownDeviceVerified, + }; + } + + private createSigningKeysUiAuthCallback(params: { + userId: string; + password?: string; + }): MatrixUiAuthCallback { + return async (makeRequest: (authData: MatrixAuthDict | null) => Promise): Promise => { + try { + return await makeRequest(null); + } catch { + // Some homeservers require an explicit dummy UIA stage even when no user interaction is needed. + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!params.password?.trim()) { + throw new Error( + "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", + ); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: params.userId }, + password: params.password, + }); + } + } + }; + } + + private async bootstrapCrossSigning( + crypto: MatrixCryptoBootstrapApi, + options: { + forceResetCrossSigning: boolean; + allowAutomaticCrossSigningReset: boolean; + allowSecretStorageRecreateWithoutRecoveryKey: boolean; + strict: boolean; + }, + ): Promise<{ ready: boolean; published: boolean }> { + const userId = await this.deps.getUserId(); + const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({ + userId, + password: this.deps.getPassword?.(), + }); + const hasPublishedCrossSigningKeys = async (): Promise => { + if (typeof crypto.userHasCrossSigningKeys !== "function") { + return true; + } + try { + return await crypto.userHasCrossSigningKeys(userId, true); + } catch { + return false; + } + }; + const isCrossSigningReady = async (): Promise => { + if (typeof crypto.isCrossSigningReady !== "function") { + return true; + } + try { + return await crypto.isCrossSigningReady(); + } catch { + return false; + } + }; + + const finalize = async (): Promise<{ ready: boolean; published: boolean }> => { + const ready = await isCrossSigningReady(); + const published = await hasPublishedCrossSigningKeys(); + if (ready && published) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return { ready, published }; + } + const message = "Cross-signing bootstrap finished but server keys are still not published"; + LogService.warn("MatrixClientLite", message); + if (options.strict) { + throw new Error(message); + } + return { ready, published }; + }; + + if (options.forceResetCrossSigning) { + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err); + if (options.strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + return { ready: false, published: false }; + } + return await finalize(); + } + + // First pass: preserve existing cross-signing identity and ensure public keys are uploaded. + try { + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + }); + } catch (err) { + const shouldRepairSecretStorage = + options.allowSecretStorageRecreateWithoutRecoveryKey && + isRepairableSecretStorageAccessError(err); + if (shouldRepairSecretStorage) { + LogService.warn( + "MatrixClientLite", + "Cross-signing bootstrap could not unlock secret storage; recreating secret storage during explicit bootstrap and retrying.", + ); + await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: true, + forceNewSecretStorage: true, + }); + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + }); + } else if (!options.allowAutomaticCrossSigningReset) { + LogService.warn( + "MatrixClientLite", + "Initial cross-signing bootstrap failed and automatic reset is disabled:", + err, + ); + return { ready: false, published: false }; + } else { + LogService.warn( + "MatrixClientLite", + "Initial cross-signing bootstrap failed, trying reset:", + err, + ); + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (resetErr) { + LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr); + if (options.strict) { + throw resetErr instanceof Error ? resetErr : new Error(String(resetErr)); + } + return { ready: false, published: false }; + } + } + } + + const firstPassReady = await isCrossSigningReady(); + const firstPassPublished = await hasPublishedCrossSigningKeys(); + if (firstPassReady && firstPassPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return { ready: true, published: true }; + } + + if (!options.allowAutomaticCrossSigningReset) { + return { ready: firstPassReady, published: firstPassPublished }; + } + + // Fallback: recover from broken local/server state by creating a fresh identity. + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); + if (options.strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + return { ready: false, published: false }; + } + + return await finalize(); + } + + private async bootstrapSecretStorage( + crypto: MatrixCryptoBootstrapApi, + options: { + strict: boolean; + allowSecretStorageRecreateWithoutRecoveryKey: boolean; + }, + ): Promise { + try { + await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: + options.allowSecretStorageRecreateWithoutRecoveryKey, + }); + LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); + if (options.strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + } + } + + private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { + if (this.verificationHandlerRegistered) { + return; + } + this.verificationHandlerRegistered = true; + + // Track incoming requests; verification lifecycle decisions live in the + // verification manager so acceptance/start/dedupe share one code path. + // Remote-user verifications are only auto-accepted. The human-operated + // client must explicitly choose "Verify by emoji" so we do not race a + // second SAS start from the bot side and end up with mismatched keys. + crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { + const verificationRequest = request as MatrixVerificationRequestLike; + try { + this.deps.verificationManager.trackVerificationRequest(verificationRequest); + } catch (err) { + LogService.warn( + "MatrixClientLite", + `Failed to track verification request from ${verificationRequest.otherUserId}:`, + err, + ); + } + }); + + this.deps.decryptBridge.bindCryptoRetrySignals(crypto); + LogService.info("MatrixClientLite", "Verification request handler registered"); + } + + private async ensureOwnDeviceTrust( + crypto: MatrixCryptoBootstrapApi, + strict = false, + ): Promise { + const deviceId = this.deps.getDeviceId()?.trim(); + if (!deviceId) { + return null; + } + const userId = await this.deps.getUserId(); + + const deviceStatus = + typeof crypto.getDeviceVerificationStatus === "function" + ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) + : null; + const alreadyVerified = isMatrixDeviceOwnerVerified(deviceStatus); + + if (alreadyVerified) { + return true; + } + + if (typeof crypto.setDeviceVerified === "function") { + await crypto.setDeviceVerified(userId, deviceId, true); + } + + if (typeof crypto.crossSignDevice === "function") { + const crossSigningReady = + typeof crypto.isCrossSigningReady === "function" + ? await crypto.isCrossSigningReady() + : true; + if (crossSigningReady) { + await crypto.crossSignDevice(deviceId); + } + } + + const refreshedStatus = + typeof crypto.getDeviceVerificationStatus === "function" + ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) + : null; + const verified = isMatrixDeviceOwnerVerified(refreshedStatus); + if (!verified && strict) { + throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`); + } + return verified; + } +} diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts new file mode 100644 index 00000000000..6d7bca7c38f --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMatrixCryptoFacade } from "./crypto-facade.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixVerificationManager } from "./verification-manager.js"; + +describe("createMatrixCryptoFacade", () => { + it("detects encrypted rooms from cached room state", async () => { + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => true, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + ensureVerificationDmTracked: vi.fn(async () => null), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + }); + + it("falls back to server room state when room cache has no encryption event", async () => { + const getRoomStateEvent = vi.fn(async () => ({ + algorithm: "m.megolm.v1.aes-sha2", + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => false, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + ensureVerificationDmTracked: vi.fn(async () => null), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent, + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", ""); + }); + + it("forwards verification requests and uses client crypto API", async () => { + const crypto = { requestOwnUserVerification: vi.fn(async () => null) }; + const requestVerification = vi.fn(async () => ({ + id: "verification-1", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: true, + phase: 2, + phaseName: "ready", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: false, + hasReciprocateQr: false, + completed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => null, + getCrypto: () => crypto, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(async () => null), + listVerifications: vi.fn(async () => []), + ensureVerificationDmTracked: vi.fn(async () => null), + requestVerification, + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({})), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + const result = await facade.requestVerification({ + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + + expect(requestVerification).toHaveBeenCalledWith(crypto, { + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + expect(result.id).toBe("verification-1"); + await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" }); + }); + + it("rehydrates in-progress DM verification requests from the raw crypto layer", async () => { + const request = { + transactionId: "txn-dm-in-progress", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + initiatedByMe: false, + isSelfVerification: false, + phase: 3, + pending: true, + accepting: false, + declining: false, + methods: ["m.sas.v1"], + accept: vi.fn(async () => {}), + cancel: vi.fn(async () => {}), + startVerification: vi.fn(), + scanQRCode: vi.fn(), + generateQRCode: vi.fn(), + on: vi.fn(), + verifier: undefined, + }; + const trackVerificationRequest = vi.fn(() => ({ + id: "verification-1", + transactionId: "txn-dm-in-progress", + roomId: "!dm:example.org", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: 3, + phaseName: "started", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: false, + hasReciprocateQr: false, + completed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + const crypto = { + requestOwnUserVerification: vi.fn(async () => null), + findVerificationRequestDMInProgress: vi.fn(() => request), + }; + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => null, + getCrypto: () => crypto, + }, + verificationManager: { + trackVerificationRequest, + requestOwnUserVerification: vi.fn(async () => null), + listVerifications: vi.fn(async () => []), + ensureVerificationDmTracked: vi.fn(async () => null), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({})), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + const summary = await facade.ensureVerificationDmTracked({ + roomId: "!dm:example.org", + userId: "@alice:example.org", + }); + + expect(crypto.findVerificationRequestDMInProgress).toHaveBeenCalledWith( + "!dm:example.org", + "@alice:example.org", + ); + expect(trackVerificationRequest).toHaveBeenCalledWith(request); + expect(summary?.transactionId).toBe("txn-dm-in-progress"); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts new file mode 100644 index 00000000000..f5e85cca26c --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.ts @@ -0,0 +1,197 @@ +import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { EncryptedFile } from "./types.js"; +import type { + MatrixVerificationCryptoApi, + MatrixVerificationManager, + MatrixVerificationMethod, + MatrixVerificationSummary, +} from "./verification-manager.js"; + +type MatrixCryptoFacadeClient = { + getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null; + getCrypto: () => unknown; +}; + +export type MatrixCryptoFacade = { + prepare: (joinedRooms: string[]) => Promise; + updateSyncData: ( + toDeviceMessages: unknown, + otkCounts: unknown, + unusedFallbackKeyAlgs: unknown, + changedDeviceLists: unknown, + leftDeviceLists: unknown, + ) => Promise; + isRoomEncrypted: (roomId: string) => Promise; + requestOwnUserVerification: () => Promise; + encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit }>; + decryptMedia: ( + file: EncryptedFile, + opts?: { maxBytes?: number; readIdleTimeoutMs?: number }, + ) => Promise; + getRecoveryKey: () => Promise<{ + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null>; + listVerifications: () => Promise; + ensureVerificationDmTracked: (params: { + roomId: string; + userId: string; + }) => Promise; + requestVerification: (params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }) => Promise; + acceptVerification: (id: string) => Promise; + cancelVerification: ( + id: string, + params?: { reason?: string; code?: string }, + ) => Promise; + startVerification: ( + id: string, + method?: MatrixVerificationMethod, + ) => Promise; + generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>; + scanVerificationQr: (id: string, qrDataBase64: string) => Promise; + confirmVerificationSas: (id: string) => Promise; + mismatchVerificationSas: (id: string) => Promise; + confirmVerificationReciprocateQr: (id: string) => Promise; + getVerificationSas: ( + id: string, + ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; +}; + +export function createMatrixCryptoFacade(deps: { + client: MatrixCryptoFacadeClient; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + getRoomStateEvent: ( + roomId: string, + eventType: string, + stateKey?: string, + ) => Promise>; + downloadContent: ( + mxcUrl: string, + opts?: { maxBytes?: number; readIdleTimeoutMs?: number }, + ) => Promise; +}): MatrixCryptoFacade { + return { + prepare: async (_joinedRooms: string[]) => { + // matrix-js-sdk performs crypto prep during startup; no extra work required here. + }, + updateSyncData: async ( + _toDeviceMessages: unknown, + _otkCounts: unknown, + _unusedFallbackKeyAlgs: unknown, + _changedDeviceLists: unknown, + _leftDeviceLists: unknown, + ) => { + // compatibility no-op + }, + isRoomEncrypted: async (roomId: string): Promise => { + const room = deps.client.getRoom(roomId); + if (room?.hasEncryptionStateEvent()) { + return true; + } + try { + const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", ""); + return typeof event.algorithm === "string" && event.algorithm.length > 0; + } catch { + return false; + } + }, + requestOwnUserVerification: async (): Promise => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestOwnUserVerification(crypto); + }, + encryptMedia: async ( + buffer: Buffer, + ): Promise<{ buffer: Buffer; file: Omit }> => { + const encrypted = Attachment.encrypt(new Uint8Array(buffer)); + const mediaInfoJson = encrypted.mediaEncryptionInfo; + if (!mediaInfoJson) { + throw new Error("Matrix media encryption failed: missing media encryption info"); + } + const parsed = JSON.parse(mediaInfoJson) as EncryptedFile; + return { + buffer: Buffer.from(encrypted.encryptedData), + file: { + key: parsed.key, + iv: parsed.iv, + hashes: parsed.hashes, + v: parsed.v, + }, + }; + }, + decryptMedia: async ( + file: EncryptedFile, + opts?: { maxBytes?: number; readIdleTimeoutMs?: number }, + ): Promise => { + const encrypted = await deps.downloadContent(file.url, opts); + const metadata: EncryptedFile = { + url: file.url, + key: file.key, + iv: file.iv, + hashes: file.hashes, + v: file.v, + }; + const attachment = new EncryptedAttachment( + new Uint8Array(encrypted), + JSON.stringify(metadata), + ); + const decrypted = Attachment.decrypt(attachment); + return Buffer.from(decrypted); + }, + getRecoveryKey: async () => { + return deps.recoveryKeyStore.getRecoveryKeySummary(); + }, + listVerifications: async () => { + return deps.verificationManager.listVerifications(); + }, + ensureVerificationDmTracked: async ({ roomId, userId }) => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + const request = + typeof crypto?.findVerificationRequestDMInProgress === "function" + ? crypto.findVerificationRequestDMInProgress(roomId, userId) + : undefined; + if (!request) { + return null; + } + return deps.verificationManager.trackVerificationRequest(request); + }, + requestVerification: async (params) => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestVerification(crypto, params); + }, + acceptVerification: async (id) => { + return await deps.verificationManager.acceptVerification(id); + }, + cancelVerification: async (id, params) => { + return await deps.verificationManager.cancelVerification(id, params); + }, + startVerification: async (id, method = "sas") => { + return await deps.verificationManager.startVerification(id, method); + }, + generateVerificationQr: async (id) => { + return await deps.verificationManager.generateVerificationQr(id); + }, + scanVerificationQr: async (id, qrDataBase64) => { + return await deps.verificationManager.scanVerificationQr(id, qrDataBase64); + }, + confirmVerificationSas: async (id) => { + return await deps.verificationManager.confirmVerificationSas(id); + }, + mismatchVerificationSas: async (id) => { + return deps.verificationManager.mismatchVerificationSas(id); + }, + confirmVerificationReciprocateQr: async (id) => { + return deps.verificationManager.confirmVerificationReciprocateQr(id); + }, + getVerificationSas: async (id) => { + return deps.verificationManager.getVerificationSas(id); + }, + }; +} diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts new file mode 100644 index 00000000000..1df9e8748bd --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -0,0 +1,307 @@ +import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk"; +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import { LogService, noop } from "./logger.js"; + +type MatrixDecryptIfNeededClient = { + decryptEventIfNeeded?: ( + event: MatrixEvent, + opts?: { + isRetry?: boolean; + }, + ) => Promise; +}; + +type MatrixDecryptRetryState = { + event: MatrixEvent; + roomId: string; + eventId: string; + attempts: number; + inFlight: boolean; + timer: ReturnType | null; +}; + +type DecryptBridgeRawEvent = { + event_id: string; +}; + +type MatrixCryptoRetrySignalSource = { + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500; +const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000; +const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8; + +function resolveDecryptRetryKey(roomId: string, eventId: string): string | null { + if (!roomId || !eventId) { + return null; + } + return `${roomId}|${eventId}`; +} + +function isDecryptionFailure(event: MatrixEvent): boolean { + return ( + typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" && + (event as { isDecryptionFailure: () => boolean }).isDecryptionFailure() + ); +} + +export class MatrixDecryptBridge { + private readonly trackedEncryptedEvents = new WeakSet(); + private readonly decryptedMessageDedupe = new Map(); + private readonly decryptRetries = new Map(); + private readonly failedDecryptionsNotified = new Set(); + private cryptoRetrySignalsBound = false; + + constructor( + private readonly deps: { + client: MatrixDecryptIfNeededClient; + toRaw: (event: MatrixEvent) => TRawEvent; + emitDecryptedEvent: (roomId: string, event: TRawEvent) => void; + emitMessage: (roomId: string, event: TRawEvent) => void; + emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void; + }, + ) {} + + shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean { + if (!eventId) { + return true; + } + const key = `${roomId}|${eventId}`; + const createdAt = this.decryptedMessageDedupe.get(key); + if (createdAt === undefined) { + return true; + } + this.decryptedMessageDedupe.delete(key); + return false; + } + + attachEncryptedEvent(event: MatrixEvent, roomId: string): void { + if (this.trackedEncryptedEvents.has(event)) { + return; + } + this.trackedEncryptedEvents.add(event); + event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => { + this.handleEncryptedEventDecrypted({ + roomId, + encryptedEvent: event, + decryptedEvent, + err, + }); + }); + } + + retryPendingNow(reason: string): void { + const pending = Array.from(this.decryptRetries.entries()); + if (pending.length === 0) { + return; + } + LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`); + for (const [retryKey, state] of pending) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + if (state.inFlight) { + continue; + } + this.runDecryptRetry(retryKey).catch(noop); + } + } + + bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void { + if (!crypto || this.cryptoRetrySignalsBound) { + return; + } + this.cryptoRetrySignalsBound = true; + + const trigger = (reason: string): void => { + this.retryPendingNow(reason); + }; + + crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => { + trigger("crypto.keyBackupDecryptionKeyCached"); + }); + crypto.on(CryptoEvent.RehydrationCompleted, () => { + trigger("dehydration.RehydrationCompleted"); + }); + crypto.on(CryptoEvent.DevicesUpdated, () => { + trigger("crypto.devicesUpdated"); + }); + crypto.on(CryptoEvent.KeysChanged, () => { + trigger("crossSigning.keysChanged"); + }); + } + + stop(): void { + for (const retryKey of this.decryptRetries.keys()) { + this.clearDecryptRetry(retryKey); + } + } + + private handleEncryptedEventDecrypted(params: { + roomId: string; + encryptedEvent: MatrixEvent; + decryptedEvent: MatrixEvent; + err?: Error; + }): void { + const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId; + const decryptedRaw = this.deps.toRaw(params.decryptedEvent); + const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || ""; + const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId); + + if (params.err) { + this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (isDecryptionFailure(params.decryptedEvent)) { + this.emitFailedDecryptionOnce( + retryKey, + decryptedRoomId, + decryptedRaw, + new Error("Matrix event failed to decrypt"), + ); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (retryKey) { + this.clearDecryptRetry(retryKey); + } + this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id); + this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw); + this.deps.emitMessage(decryptedRoomId, decryptedRaw); + } + + private emitFailedDecryptionOnce( + retryKey: string | null, + roomId: string, + event: TRawEvent, + error: Error, + ): void { + if (retryKey) { + if (this.failedDecryptionsNotified.has(retryKey)) { + return; + } + this.failedDecryptionsNotified.add(retryKey); + } + this.deps.emitFailedDecryption(roomId, event, error); + } + + private scheduleDecryptRetry(params: { + event: MatrixEvent; + roomId: string; + eventId: string; + }): void { + const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId); + if (!retryKey) { + return; + } + const existing = this.decryptRetries.get(retryKey); + if (existing?.timer || existing?.inFlight) { + return; + } + const attempts = (existing?.attempts ?? 0) + 1; + if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) { + this.clearDecryptRetry(retryKey); + LogService.debug( + "MatrixClientLite", + `Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`, + ); + return; + } + const delayMs = Math.min( + MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), + MATRIX_DECRYPT_RETRY_MAX_DELAY_MS, + ); + const next: MatrixDecryptRetryState = { + event: params.event, + roomId: params.roomId, + eventId: params.eventId, + attempts, + inFlight: false, + timer: null, + }; + next.timer = setTimeout(() => { + this.runDecryptRetry(retryKey).catch(noop); + }, delayMs); + this.decryptRetries.set(retryKey, next); + } + + private async runDecryptRetry(retryKey: string): Promise { + const state = this.decryptRetries.get(retryKey); + if (!state || state.inFlight) { + return; + } + + state.inFlight = true; + state.timer = null; + const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; + if (!canDecrypt) { + this.clearDecryptRetry(retryKey); + return; + } + + try { + await this.deps.client.decryptEventIfNeeded?.(state.event, { + isRetry: true, + }); + } catch { + // Retry with backoff until we hit the configured retry cap. + } finally { + state.inFlight = false; + } + + if (isDecryptionFailure(state.event)) { + this.scheduleDecryptRetry(state); + return; + } + + this.clearDecryptRetry(retryKey); + } + + private clearDecryptRetry(retryKey: string): void { + const state = this.decryptRetries.get(retryKey); + if (state?.timer) { + clearTimeout(state.timer); + } + this.decryptRetries.delete(retryKey); + this.failedDecryptionsNotified.delete(retryKey); + } + + private rememberDecryptedMessage(roomId: string, eventId: string): void { + if (!eventId) { + return; + } + const now = Date.now(); + this.pruneDecryptedMessageDedupe(now); + this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now); + } + + private pruneDecryptedMessageDedupe(now: number): void { + const ttlMs = 30_000; + for (const [key, createdAt] of this.decryptedMessageDedupe) { + if (now - createdAt > ttlMs) { + this.decryptedMessageDedupe.delete(key); + } + } + const maxEntries = 2048; + while (this.decryptedMessageDedupe.size > maxEntries) { + const oldest = this.decryptedMessageDedupe.keys().next().value; + if (oldest === undefined) { + break; + } + this.decryptedMessageDedupe.delete(oldest); + } + } +} diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts new file mode 100644 index 00000000000..b3fff8fc52b --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts @@ -0,0 +1,60 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import { describe, expect, it } from "vitest"; +import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js"; + +describe("event-helpers", () => { + it("parses mxc URIs", () => { + expect(parseMxc("mxc://server.example/media-id")).toEqual({ + server: "server.example", + mediaId: "media-id", + }); + expect(parseMxc("not-mxc")).toBeNull(); + }); + + it("builds HTTP errors from JSON and plain text payloads", () => { + const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" })); + expect(fromJson.message).toBe("forbidden"); + expect(fromJson.statusCode).toBe(403); + + const fromText = buildHttpError(500, "internal failure"); + expect(fromText.message).toBe("internal failure"); + expect(fromText.statusCode).toBe(500); + }); + + it("serializes Matrix events and resolves state key from available sources", () => { + const viaGetter = { + getId: () => "$1", + getSender: () => "@alice:example.org", + getType: () => "m.room.member", + getTs: () => 1000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({ age: 1 }), + getStateKey: () => "@alice:example.org", + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org"); + + const viaWire = { + getId: () => "$2", + getSender: () => "@bob:example.org", + getType: () => "m.room.member", + getTs: () => 2000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + getWireContent: () => ({ state_key: "@bob:example.org" }), + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org"); + + const viaRaw = { + getId: () => "$3", + getSender: () => "@carol:example.org", + getType: () => "m.room.member", + getTs: () => 3000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + event: { state_key: "@carol:example.org" }, + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org"); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.ts b/extensions/matrix/src/matrix/sdk/event-helpers.ts new file mode 100644 index 00000000000..b9e62f3a944 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/event-helpers.ts @@ -0,0 +1,71 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import type { MatrixRawEvent } from "./types.js"; + +export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent { + const unsigned = (event.getUnsigned?.() ?? {}) as { + age?: number; + redacted_because?: unknown; + }; + const raw: MatrixRawEvent = { + event_id: event.getId() ?? "", + sender: event.getSender() ?? "", + type: event.getType() ?? "", + origin_server_ts: event.getTs() ?? 0, + content: ((event.getContent?.() ?? {}) as Record) || {}, + unsigned, + }; + const stateKey = resolveMatrixStateKey(event); + if (typeof stateKey === "string") { + raw.state_key = stateKey; + } + return raw; +} + +export function parseMxc(url: string): { server: string; mediaId: string } | null { + const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); + if (!match) { + return null; + } + return { + server: match[1], + mediaId: match[2], + }; +} + +export function buildHttpError( + statusCode: number, + bodyText: string, +): Error & { statusCode: number } { + let message = `Matrix HTTP ${statusCode}`; + if (bodyText.trim()) { + try { + const parsed = JSON.parse(bodyText) as { error?: string }; + if (typeof parsed.error === "string" && parsed.error.trim()) { + message = parsed.error.trim(); + } else { + message = bodyText.slice(0, 500); + } + } catch { + message = bodyText.slice(0, 500); + } + } + return Object.assign(new Error(message), { statusCode }); +} + +function resolveMatrixStateKey(event: MatrixEvent): string | undefined { + const direct = event.getStateKey?.(); + if (typeof direct === "string") { + return direct; + } + const wireContent = ( + event as { getWireContent?: () => { state_key?: unknown } } + ).getWireContent?.(); + if (wireContent && typeof wireContent.state_key === "string") { + return wireContent.state_key; + } + const rawEvent = (event as { event?: { state_key?: unknown } }).event; + if (rawEvent && typeof rawEvent.state_key === "string") { + return rawEvent.state_key; + } + return undefined; +} diff --git a/extensions/matrix/src/matrix/sdk/http-client.test.ts b/extensions/matrix/src/matrix/sdk/http-client.test.ts new file mode 100644 index 00000000000..f2b7ed59ee6 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/http-client.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { performMatrixRequestMock } = vi.hoisted(() => ({ + performMatrixRequestMock: vi.fn(), +})); + +vi.mock("./transport.js", () => ({ + performMatrixRequest: performMatrixRequestMock, +})); + +import { MatrixAuthedHttpClient } from "./http-client.js"; + +describe("MatrixAuthedHttpClient", () => { + beforeEach(() => { + performMatrixRequestMock.mockReset(); + }); + + it("parses JSON responses and forwards absolute-endpoint opt-in", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + text: '{"ok":true}', + buffer: Buffer.from('{"ok":true}', "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + timeoutMs: 5000, + allowAbsoluteEndpoint: true, + }); + + expect(result).toEqual({ ok: true }); + expect(performMatrixRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + allowAbsoluteEndpoint: true, + }), + ); + }); + + it("returns plain text when response is not JSON", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response("pong", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + text: "pong", + buffer: Buffer.from("pong", "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/ping", + timeoutMs: 5000, + }); + + expect(result).toBe("pong"); + }); + + it("returns raw buffers for media requests", async () => { + const payload = Buffer.from([1, 2, 3, 4]); + performMatrixRequestMock.mockResolvedValue({ + response: new Response(payload, { status: 200 }), + text: payload.toString("utf8"), + buffer: payload, + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestRaw({ + method: "GET", + endpoint: "/_matrix/media/v3/download/example/id", + timeoutMs: 5000, + }); + + expect(result).toEqual(payload); + }); + + it("raises HTTP errors with status code metadata", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response(JSON.stringify({ error: "forbidden" }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + text: JSON.stringify({ error: "forbidden" }), + buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + await expect( + client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/rooms", + timeoutMs: 5000, + }), + ).rejects.toMatchObject({ + message: "forbidden", + statusCode: 403, + }); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/http-client.ts b/extensions/matrix/src/matrix/sdk/http-client.ts new file mode 100644 index 00000000000..638c845d48c --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/http-client.ts @@ -0,0 +1,67 @@ +import { buildHttpError } from "./event-helpers.js"; +import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js"; + +export class MatrixAuthedHttpClient { + constructor( + private readonly homeserver: string, + private readonly accessToken: string, + ) {} + + async requestJson(params: { + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + body?: unknown; + timeoutMs: number; + allowAbsoluteEndpoint?: boolean; + }): Promise { + const { response, text } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, + method: params.method, + endpoint: params.endpoint, + qs: params.qs, + body: params.body, + timeoutMs: params.timeoutMs, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, + }); + if (!response.ok) { + throw buildHttpError(response.status, text); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + if (!text.trim()) { + return {}; + } + return JSON.parse(text); + } + return text; + } + + async requestRaw(params: { + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + timeoutMs: number; + maxBytes?: number; + readIdleTimeoutMs?: number; + allowAbsoluteEndpoint?: boolean; + }): Promise { + const { response, buffer } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, + method: params.method, + endpoint: params.endpoint, + qs: params.qs, + timeoutMs: params.timeoutMs, + raw: true, + maxBytes: params.maxBytes, + readIdleTimeoutMs: params.readIdleTimeoutMs, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, + }); + if (!response.ok) { + throw buildHttpError(response.status, buffer.toString("utf8")); + } + return buffer; + } +} diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts new file mode 100644 index 00000000000..0c62f319583 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.test.ts @@ -0,0 +1,174 @@ +import "fake-indexeddb/auto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { persistIdbToDisk, restoreIdbFromDisk } from "./idb-persistence.js"; +import { LogService } from "./logger.js"; + +async function clearAllIndexedDbState(): Promise { + const databases = await indexedDB.databases(); + await Promise.all( + databases + .map((entry) => entry.name) + .filter((name): name is string => Boolean(name)) + .map( + (name) => + new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }), + ), + ); +} + +async function seedDatabase(params: { + name: string; + version?: number; + storeName: string; + records: Array<{ key: IDBValidKey; value: unknown }>; +}): Promise { + await new Promise((resolve, reject) => { + const req = indexedDB.open(params.name, params.version ?? 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(params.storeName)) { + db.createObjectStore(params.storeName); + } + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction(params.storeName, "readwrite"); + const store = tx.objectStore(params.storeName); + for (const record of params.records) { + store.put(record.value, record.key); + } + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + req.onerror = () => reject(req.error); + }); +} + +async function readDatabaseRecords(params: { + name: string; + version?: number; + storeName: string; +}): Promise> { + return await new Promise((resolve, reject) => { + const req = indexedDB.open(params.name, params.version ?? 1); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction(params.storeName, "readonly"); + const store = tx.objectStore(params.storeName); + const keysReq = store.getAllKeys(); + const valuesReq = store.getAll(); + let keys: IDBValidKey[] | null = null; + let values: unknown[] | null = null; + + const maybeResolve = () => { + if (!keys || !values) { + return; + } + db.close(); + const resolvedValues = values; + resolve(keys.map((key, index) => ({ key, value: resolvedValues[index] }))); + }; + + keysReq.onsuccess = () => { + keys = keysReq.result; + maybeResolve(); + }; + valuesReq.onsuccess = () => { + values = valuesReq.result; + maybeResolve(); + }; + keysReq.onerror = () => reject(keysReq.error); + valuesReq.onerror = () => reject(valuesReq.error); + }; + req.onerror = () => reject(req.error); + }); +} + +describe("Matrix IndexedDB persistence", () => { + let tmpDir: string; + let warnSpy: ReturnType; + + beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-idb-persist-")); + warnSpy = vi.spyOn(LogService, "warn").mockImplementation(() => {}); + await clearAllIndexedDbState(); + }); + + afterEach(async () => { + warnSpy.mockRestore(); + await clearAllIndexedDbState(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("persists and restores database contents for the selected prefix", async () => { + const snapshotPath = path.join(tmpDir, "crypto-idb-snapshot.json"); + await seedDatabase({ + name: "openclaw-matrix-test::matrix-sdk-crypto", + storeName: "sessions", + records: [{ key: "room-1", value: { session: "abc123" } }], + }); + await seedDatabase({ + name: "other-prefix::matrix-sdk-crypto", + storeName: "sessions", + records: [{ key: "room-2", value: { session: "should-not-restore" } }], + }); + + await persistIdbToDisk({ + snapshotPath, + databasePrefix: "openclaw-matrix-test", + }); + expect(fs.existsSync(snapshotPath)).toBe(true); + + const mode = fs.statSync(snapshotPath).mode & 0o777; + expect(mode).toBe(0o600); + + await clearAllIndexedDbState(); + + const restored = await restoreIdbFromDisk(snapshotPath); + expect(restored).toBe(true); + + const restoredRecords = await readDatabaseRecords({ + name: "openclaw-matrix-test::matrix-sdk-crypto", + storeName: "sessions", + }); + expect(restoredRecords).toEqual([{ key: "room-1", value: { session: "abc123" } }]); + + const dbs = await indexedDB.databases(); + expect(dbs.some((entry) => entry.name === "other-prefix::matrix-sdk-crypto")).toBe(false); + }); + + it("returns false and logs a warning for malformed snapshots", async () => { + const snapshotPath = path.join(tmpDir, "bad-snapshot.json"); + fs.writeFileSync(snapshotPath, JSON.stringify([{ nope: true }]), "utf8"); + + const restored = await restoreIdbFromDisk(snapshotPath); + expect(restored).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + "IdbPersistence", + expect.stringContaining(`Failed to restore IndexedDB snapshot from ${snapshotPath}:`), + expect.any(Error), + ); + }); + + it("returns false for empty snapshot payloads without restoring databases", async () => { + const snapshotPath = path.join(tmpDir, "empty-snapshot.json"); + fs.writeFileSync(snapshotPath, JSON.stringify([]), "utf8"); + + const restored = await restoreIdbFromDisk(snapshotPath); + expect(restored).toBe(false); + + const dbs = await indexedDB.databases(); + expect(dbs).toEqual([]); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/idb-persistence.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.ts new file mode 100644 index 00000000000..51f86c8e175 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.ts @@ -0,0 +1,244 @@ +import fs from "node:fs"; +import path from "node:path"; +import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; +import { LogService } from "./logger.js"; + +type IdbStoreSnapshot = { + name: string; + keyPath: IDBObjectStoreParameters["keyPath"]; + autoIncrement: boolean; + indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[]; + records: { key: IDBValidKey; value: unknown }[]; +}; + +type IdbDatabaseSnapshot = { + name: string; + version: number; + stores: IdbStoreSnapshot[]; +}; + +function isValidIdbIndexSnapshot(value: unknown): value is IdbStoreSnapshot["indexes"][number] { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.name === "string" && + (typeof candidate.keyPath === "string" || + (Array.isArray(candidate.keyPath) && + candidate.keyPath.every((entry) => typeof entry === "string"))) && + typeof candidate.multiEntry === "boolean" && + typeof candidate.unique === "boolean" + ); +} + +function isValidIdbRecordSnapshot(value: unknown): value is IdbStoreSnapshot["records"][number] { + if (!value || typeof value !== "object") { + return false; + } + return "key" in value && "value" in value; +} + +function isValidIdbStoreSnapshot(value: unknown): value is IdbStoreSnapshot { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + const validKeyPath = + candidate.keyPath === null || + typeof candidate.keyPath === "string" || + (Array.isArray(candidate.keyPath) && + candidate.keyPath.every((entry) => typeof entry === "string")); + return ( + typeof candidate.name === "string" && + validKeyPath && + typeof candidate.autoIncrement === "boolean" && + Array.isArray(candidate.indexes) && + candidate.indexes.every((entry) => isValidIdbIndexSnapshot(entry)) && + Array.isArray(candidate.records) && + candidate.records.every((entry) => isValidIdbRecordSnapshot(entry)) + ); +} + +function isValidIdbDatabaseSnapshot(value: unknown): value is IdbDatabaseSnapshot { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.name === "string" && + typeof candidate.version === "number" && + Number.isFinite(candidate.version) && + candidate.version > 0 && + Array.isArray(candidate.stores) && + candidate.stores.every((entry) => isValidIdbStoreSnapshot(entry)) + ); +} + +function parseSnapshotPayload(data: string): IdbDatabaseSnapshot[] | null { + const parsed = JSON.parse(data) as unknown; + if (!Array.isArray(parsed) || parsed.length === 0) { + return null; + } + if (!parsed.every((entry) => isValidIdbDatabaseSnapshot(entry))) { + throw new Error("Malformed IndexedDB snapshot payload"); + } + return parsed; +} + +function idbReq(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dumpIndexedDatabases(databasePrefix?: string): Promise { + const idb = fakeIndexedDB; + const dbList = await idb.databases(); + const snapshot: IdbDatabaseSnapshot[] = []; + const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null; + + for (const { name, version } of dbList) { + if (!name || !version) continue; + if (expectedPrefix && !name.startsWith(expectedPrefix)) continue; + const db: IDBDatabase = await new Promise((resolve, reject) => { + const r = idb.open(name, version); + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + + const stores: IdbStoreSnapshot[] = []; + for (const storeName of db.objectStoreNames) { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const storeInfo: IdbStoreSnapshot = { + name: storeName, + keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"], + autoIncrement: store.autoIncrement, + indexes: [], + records: [], + }; + for (const idxName of store.indexNames) { + const idx = store.index(idxName); + storeInfo.indexes.push({ + name: idxName, + keyPath: idx.keyPath as string | string[], + multiEntry: idx.multiEntry, + unique: idx.unique, + }); + } + const keys = await idbReq(store.getAllKeys()); + const values = await idbReq(store.getAll()); + storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] })); + stores.push(storeInfo); + } + snapshot.push({ name, version, stores }); + db.close(); + } + return snapshot; +} + +async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise { + const idb = fakeIndexedDB; + for (const dbSnap of snapshot) { + await new Promise((resolve, reject) => { + const r = idb.open(dbSnap.name, dbSnap.version); + r.onupgradeneeded = () => { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + const opts: IDBObjectStoreParameters = {}; + if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath; + if (storeSnap.autoIncrement) opts.autoIncrement = true; + const store = db.createObjectStore(storeSnap.name, opts); + for (const idx of storeSnap.indexes) { + store.createIndex(idx.name, idx.keyPath, { + unique: idx.unique, + multiEntry: idx.multiEntry, + }); + } + } + }; + r.onsuccess = async () => { + try { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + if (storeSnap.records.length === 0) continue; + const tx = db.transaction(storeSnap.name, "readwrite"); + const store = tx.objectStore(storeSnap.name); + for (const rec of storeSnap.records) { + if (storeSnap.keyPath !== null) { + store.put(rec.value); + } else { + store.put(rec.value, rec.key); + } + } + await new Promise((res) => { + tx.oncomplete = () => res(); + }); + } + db.close(); + resolve(); + } catch (err) { + reject(err); + } + }; + r.onerror = () => reject(r.error); + }); + } +} + +function resolveDefaultIdbSnapshotPath(): string { + const stateDir = + process.env.OPENCLAW_STATE_DIR || + process.env.MOLTBOT_STATE_DIR || + path.join(process.env.HOME || "/tmp", ".openclaw"); + return path.join(stateDir, "matrix", "crypto-idb-snapshot.json"); +} + +export async function restoreIdbFromDisk(snapshotPath?: string): Promise { + const candidatePaths = snapshotPath ? [snapshotPath] : [resolveDefaultIdbSnapshotPath()]; + for (const resolvedPath of candidatePaths) { + try { + const data = fs.readFileSync(resolvedPath, "utf8"); + const snapshot = parseSnapshotPayload(data); + if (!snapshot) { + continue; + } + await restoreIndexedDatabases(snapshot); + LogService.info( + "IdbPersistence", + `Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`, + ); + return true; + } catch (err) { + LogService.warn( + "IdbPersistence", + `Failed to restore IndexedDB snapshot from ${resolvedPath}:`, + err, + ); + continue; + } + } + return false; +} + +export async function persistIdbToDisk(params?: { + snapshotPath?: string; + databasePrefix?: string; +}): Promise { + const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath(); + try { + const snapshot = await dumpIndexedDatabases(params?.databasePrefix); + if (snapshot.length === 0) return; + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot)); + fs.chmodSync(snapshotPath, 0o600); + LogService.debug( + "IdbPersistence", + `Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`, + ); + } catch (err) { + LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err); + } +} diff --git a/extensions/matrix/src/matrix/sdk/logger.test.ts b/extensions/matrix/src/matrix/sdk/logger.test.ts new file mode 100644 index 00000000000..b21168b6520 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/logger.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConsoleLogger, setMatrixConsoleLogging } from "./logger.js"; + +describe("ConsoleLogger", () => { + afterEach(() => { + setMatrixConsoleLogging(false); + vi.restoreAllMocks(); + }); + + it("redacts sensitive tokens in emitted log messages", () => { + setMatrixConsoleLogging(true); + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + + new ConsoleLogger().error( + "MatrixHttpClient", + "Authorization: Bearer 123456:abcdefghijklmnopqrstuvwxyzABCDEFG", + ); + + const message = spy.mock.calls[0]?.[0]; + expect(typeof message).toBe("string"); + expect(message).toContain("Authorization: Bearer"); + expect(message).not.toContain("123456:abcdefghijklmnopqrstuvwxyzABCDEFG"); + expect(message).toContain("***"); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts new file mode 100644 index 00000000000..f3f08fe7cdc --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -0,0 +1,107 @@ +import { format } from "node:util"; +import { redactSensitiveText, type RuntimeLogger } from "openclaw/plugin-sdk/matrix"; +import { getMatrixRuntime } from "../../runtime.js"; + +export type Logger = { + 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; +}; + +export function noop(): void { + // no-op +} + +let forceConsoleLogging = false; + +export function setMatrixConsoleLogging(enabled: boolean): void { + forceConsoleLogging = enabled; +} + +function resolveRuntimeLogger(module: string): RuntimeLogger | null { + if (forceConsoleLogging) { + return null; + } + try { + return getMatrixRuntime().logging.getChildLogger({ module: `matrix:${module}` }); + } catch { + return null; + } +} + +function formatMessage(module: string, messageOrObject: unknown[]): string { + if (messageOrObject.length === 0) { + return `[${module}]`; + } + return redactSensitiveText(`[${module}] ${format(...messageOrObject)}`); +} + +export class ConsoleLogger { + private emit( + level: "debug" | "info" | "warn" | "error", + module: string, + ...messageOrObject: unknown[] + ): void { + const runtimeLogger = resolveRuntimeLogger(module); + const message = formatMessage(module, messageOrObject); + if (runtimeLogger) { + if (level === "debug") { + runtimeLogger.debug?.(message); + return; + } + runtimeLogger[level](message); + return; + } + if (level === "debug") { + console.debug(message); + return; + } + console[level](message); + } + + trace(module: string, ...messageOrObject: unknown[]): void { + this.emit("debug", module, ...messageOrObject); + } + + debug(module: string, ...messageOrObject: unknown[]): void { + this.emit("debug", module, ...messageOrObject); + } + + info(module: string, ...messageOrObject: unknown[]): void { + this.emit("info", module, ...messageOrObject); + } + + warn(module: string, ...messageOrObject: unknown[]): void { + this.emit("warn", module, ...messageOrObject); + } + + error(module: string, ...messageOrObject: unknown[]): void { + this.emit("error", module, ...messageOrObject); + } +} + +const defaultLogger = new ConsoleLogger(); +let activeLogger: Logger = defaultLogger; + +export const LogService = { + setLogger(logger: Logger): void { + activeLogger = logger; + }, + trace(module: string, ...messageOrObject: unknown[]): void { + activeLogger.trace(module, ...messageOrObject); + }, + debug(module: string, ...messageOrObject: unknown[]): void { + activeLogger.debug(module, ...messageOrObject); + }, + info(module: string, ...messageOrObject: unknown[]): void { + activeLogger.info(module, ...messageOrObject); + }, + warn(module: string, ...messageOrObject: unknown[]): void { + activeLogger.warn(module, ...messageOrObject); + }, + error(module: string, ...messageOrObject: unknown[]): void { + activeLogger.error(module, ...messageOrObject); + }, +}; diff --git a/extensions/matrix/src/matrix/sdk/read-response-with-limit.ts b/extensions/matrix/src/matrix/sdk/read-response-with-limit.ts new file mode 100644 index 00000000000..2077f56e5c3 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/read-response-with-limit.ts @@ -0,0 +1,95 @@ +async function readChunkWithIdleTimeout( + reader: ReadableStreamDefaultReader, + chunkTimeoutMs: number, +): Promise>> { + let timeoutId: ReturnType | undefined; + let timedOut = false; + + return await new Promise((resolve, reject) => { + const clear = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; + + timeoutId = setTimeout(() => { + timedOut = true; + clear(); + void reader.cancel().catch(() => undefined); + reject(new Error(`Matrix media download stalled: no data received for ${chunkTimeoutMs}ms`)); + }, chunkTimeoutMs); + + void reader.read().then( + (result) => { + clear(); + if (!timedOut) { + resolve(result); + } + }, + (err) => { + clear(); + if (!timedOut) { + reject(err); + } + }, + ); + }); +} + +export async function readResponseWithLimit( + res: Response, + maxBytes: number, + opts?: { + onOverflow?: (params: { size: number; maxBytes: number; res: Response }) => Error; + chunkTimeoutMs?: number; + }, +): Promise { + const onOverflow = + opts?.onOverflow ?? + ((params: { size: number; maxBytes: number }) => + new Error(`Content too large: ${params.size} bytes (limit: ${params.maxBytes} bytes)`)); + const chunkTimeoutMs = opts?.chunkTimeoutMs; + + const body = res.body; + if (!body || typeof body.getReader !== "function") { + const fallback = Buffer.from(await res.arrayBuffer()); + if (fallback.length > maxBytes) { + throw onOverflow({ size: fallback.length, maxBytes, res }); + } + return fallback; + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = chunkTimeoutMs + ? await readChunkWithIdleTimeout(reader, chunkTimeoutMs) + : await reader.read(); + if (done) { + break; + } + if (value?.length) { + total += value.length; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch {} + throw onOverflow({ size: total, maxBytes, res }); + } + chunks.push(value); + } + } + } finally { + try { + reader.releaseLock(); + } catch {} + } + + return Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk)), + total, + ); +} diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts new file mode 100644 index 00000000000..79d41b0e36b --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts @@ -0,0 +1,383 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixCryptoBootstrapApi } from "./types.js"; + +function createTempRecoveryKeyPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-")); + return path.join(dir, "recovery-key.json"); +} + +describe("MatrixRecoveryKeyStore", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads a stored recovery key for requested secret-storage keys", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSS", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + const resolved = await callbacks.getSecretStorageKey?.( + { keys: { SSSS: { name: "test" } } }, + "m.cross_signing.master", + ); + + expect(resolved?.[0]).toBe("SSSS"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("persists cached secret-storage keys with secure file permissions", () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + + callbacks.cacheSecretStorageKey?.( + "KEY123", + { + name: "openclaw", + }, + new Uint8Array([9, 8, 7]), + ); + + const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + privateKeyBase64?: string; + }; + expect(saved.keyId).toBe("KEY123"); + expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64")); + + const mode = fs.statSync(recoveryKeyPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("creates and persists a recovery key when secret storage is missing", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "GENERATED", + keyInfo: { name: "generated" }, + privateKey: new Uint8Array([5, 6, 7, 8]), + encodedPrivateKey: "encoded-generated-key", // pragma: allowlist secret + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "GENERATED", + encodedPrivateKey: "encoded-generated-key", // pragma: allowlist secret + }); + }); + + it("rebinds stored recovery key to server default key id when it changes", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "OLD", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + + const bootstrapSecretStorage = vi.fn(async () => {}); + const createRecoveryKeyFromPassphrase = vi.fn(async () => { + throw new Error("should not be called"); + }); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled(); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "NEW", + }); + }); + + it("recreates secret storage when default key exists but is not usable locally", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "RECOVERED", + keyInfo: { name: "recovered" }, + privateKey: new Uint8Array([1, 1, 2, 3]), + encodedPrivateKey: "encoded-recovered-key", // pragma: allowlist secret + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "RECOVERED", + encodedPrivateKey: "encoded-recovered-key", // pragma: allowlist secret + }); + }); + + it("recreates secret storage during explicit bootstrap when the server key exists but no local recovery key is available", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "REPAIRED", + keyInfo: { name: "repaired" }, + privateKey: new Uint8Array([7, 7, 8, 9]), + encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { + setupNewSecretStorage?: boolean; + createSecretStorageKey?: () => Promise; + }) => { + if (opts?.setupNewSecretStorage) { + await opts.createSecretStorageKey?.(); + return; + } + throw new Error("getSecretStorageKey callback returned falsey"); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "LEGACY", + secretStorageKeyValidityMap: { LEGACY: true }, + })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: true, + }); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2); + expect(bootstrapSecretStorage).toHaveBeenLastCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "REPAIRED", + encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret + }); + }); + + it("recreates secret storage during explicit bootstrap when decrypting a stored secret fails with bad MAC", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "REPAIRED", + keyInfo: { name: "repaired" }, + privateKey: new Uint8Array([7, 7, 8, 9]), + encodedPrivateKey: "encoded-repaired-key", // pragma: allowlist secret + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { + setupNewSecretStorage?: boolean; + createSecretStorageKey?: () => Promise; + }) => { + if (opts?.setupNewSecretStorage) { + await opts.createSecretStorageKey?.(); + return; + } + throw new Error("Error decrypting secret m.cross_signing.master: bad MAC"); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "LEGACY", + secretStorageKeyValidityMap: { LEGACY: true }, + })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto, { + allowSecretStorageRecreateWithoutRecoveryKey: true, + }); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledTimes(2); + expect(bootstrapSecretStorage).toHaveBeenLastCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + }); + + it("stores an encoded recovery key and decodes its private key material", () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + expect(encoded).toBeTypeOf("string"); + + const summary = store.storeEncodedRecoveryKey({ + encodedPrivateKey: encoded as string, + keyId: "SSSSKEY", + }); + + expect(summary.keyId).toBe("SSSSKEY"); + expect(summary.encodedPrivateKey).toBe(encoded); + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + privateKeyBase64?: string; + keyId?: string; + }; + expect(persisted.keyId).toBe("SSSSKEY"); + expect( + Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals( + Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)), + ), + ).toBe(true); + }); + + it("stages a recovery key for secret storage without persisting it until commit", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.rmSync(recoveryKeyPath, { force: true }); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const encoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 11) % 255)), + ); + expect(encoded).toBeTypeOf("string"); + + store.stageEncodedRecoveryKey({ + encodedPrivateKey: encoded as string, + keyId: "SSSSKEY", + }); + + expect(fs.existsSync(recoveryKeyPath)).toBe(false); + const callbacks = store.buildCryptoCallbacks(); + const resolved = await callbacks.getSecretStorageKey?.( + { keys: { SSSSKEY: { name: "test" } } }, + "m.cross_signing.master", + ); + expect(resolved?.[0]).toBe("SSSSKEY"); + + store.commitStagedRecoveryKey({ keyId: "SSSSKEY" }); + + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + encodedPrivateKey?: string; + }; + expect(persisted.keyId).toBe("SSSSKEY"); + expect(persisted.encodedPrivateKey).toBe(encoded); + }); + + it("does not overwrite the stored recovery key while a staged key is only being validated", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const storedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)), + ); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: "2026-03-12T00:00:00.000Z", + keyId: "OLD", + encodedPrivateKey: storedEncoded, + privateKeyBase64: Buffer.from( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 1) % 255)), + ).toString("base64"), + }), + "utf8", + ); + + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const stagedEncoded = encodeRecoveryKey( + new Uint8Array(Array.from({ length: 32 }, (_, i) => (i + 101) % 255)), + ); + store.stageEncodedRecoveryKey({ + encodedPrivateKey: stagedEncoded as string, + keyId: "NEW", + }); + + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + createRecoveryKeyFromPassphrase: vi.fn(async () => { + throw new Error("should not be called"); + }), + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + encodedPrivateKey?: string; + }; + expect(persisted.keyId).toBe("OLD"); + expect(persisted.encodedPrivateKey).toBe(storedEncoded); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts new file mode 100644 index 00000000000..f12a4a0ae29 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts @@ -0,0 +1,426 @@ +import fs from "node:fs"; +import path from "node:path"; +import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { LogService } from "./logger.js"; +import type { + MatrixCryptoBootstrapApi, + MatrixCryptoCallbacks, + MatrixGeneratedSecretStorageKey, + MatrixSecretStorageStatus, + MatrixStoredRecoveryKey, +} from "./types.js"; + +export function isRepairableSecretStorageAccessError(err: unknown): boolean { + const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); + if (!message) { + return false; + } + if (message.includes("getsecretstoragekey callback returned falsey")) { + return true; + } + // The homeserver still has secret storage, but the local recovery key cannot + // authenticate/decrypt a required secret. During explicit bootstrap we can + // recreate secret storage and continue with a new local baseline. + if (message.includes("decrypting secret") && message.includes("bad mac")) { + return true; + } + return false; +} + +export class MatrixRecoveryKeyStore { + private readonly secretStorageKeyCache = new Map< + string, + { key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] } + >(); + private stagedRecoveryKey: MatrixStoredRecoveryKey | null = null; + private readonly stagedCacheKeyIds = new Set(); + + constructor(private readonly recoveryKeyPath?: string) {} + + buildCryptoCallbacks(): MatrixCryptoCallbacks { + return { + getSecretStorageKey: async ({ keys }) => { + const requestedKeyIds = Object.keys(keys ?? {}); + if (requestedKeyIds.length === 0) { + return null; + } + + for (const keyId of requestedKeyIds) { + const cached = this.secretStorageKeyCache.get(keyId); + if (cached) { + return [keyId, new Uint8Array(cached.key)]; + } + } + + const staged = this.stagedRecoveryKey; + if (staged?.privateKeyBase64) { + const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64")); + if (privateKey.length > 0) { + const stagedKeyId = + staged.keyId && requestedKeyIds.includes(staged.keyId) + ? staged.keyId + : requestedKeyIds[0]; + if (stagedKeyId) { + this.rememberSecretStorageKey(stagedKeyId, privateKey, staged.keyInfo); + this.stagedCacheKeyIds.add(stagedKeyId); + return [stagedKeyId, privateKey]; + } + } + } + + const stored = this.loadStoredRecoveryKey(); + if (!stored?.privateKeyBase64) { + return null; + } + const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64")); + if (privateKey.length === 0) { + return null; + } + + if (stored.keyId && requestedKeyIds.includes(stored.keyId)) { + this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo); + return [stored.keyId, privateKey]; + } + + const firstRequestedKeyId = requestedKeyIds[0]; + if (!firstRequestedKeyId) { + return null; + } + this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo); + return [firstRequestedKeyId, privateKey]; + }, + cacheSecretStorageKey: (keyId, keyInfo, key) => { + const privateKey = new Uint8Array(key); + const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = { + passphrase: keyInfo?.passphrase, + name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined, + }; + this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo); + + const stored = this.loadStoredRecoveryKey(); + this.saveRecoveryKeyToDisk({ + keyId, + keyInfo: normalizedKeyInfo, + privateKey, + encodedPrivateKey: stored?.encodedPrivateKey, + }); + }, + }; + } + + getRecoveryKeySummary(): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null { + const stored = this.loadStoredRecoveryKey(); + if (!stored) { + return null; + } + return { + encodedPrivateKey: stored.encodedPrivateKey, + keyId: stored.keyId, + createdAt: stored.createdAt, + }; + } + + storeEncodedRecoveryKey(params: { + encodedPrivateKey: string; + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } { + const encodedPrivateKey = params.encodedPrivateKey.trim(); + if (!encodedPrivateKey) { + throw new Error("Matrix recovery key is required"); + } + let privateKey: Uint8Array; + try { + privateKey = decodeRecoveryKey(encodedPrivateKey); + } catch (err) { + throw new Error( + `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const normalizedKeyId = + typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; + const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo; + this.saveRecoveryKeyToDisk({ + keyId: normalizedKeyId, + keyInfo, + privateKey, + encodedPrivateKey, + }); + if (normalizedKeyId) { + this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo); + } + return this.getRecoveryKeySummary() ?? {}; + } + + stageEncodedRecoveryKey(params: { + encodedPrivateKey: string; + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): void { + const encodedPrivateKey = params.encodedPrivateKey.trim(); + if (!encodedPrivateKey) { + throw new Error("Matrix recovery key is required"); + } + let privateKey: Uint8Array; + try { + privateKey = decodeRecoveryKey(encodedPrivateKey); + } catch (err) { + throw new Error( + `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const normalizedKeyId = + typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; + this.discardStagedRecoveryKey(); + this.stagedRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: normalizedKeyId, + encodedPrivateKey, + privateKeyBase64: Buffer.from(privateKey).toString("base64"), + keyInfo: params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo, + }; + } + + commitStagedRecoveryKey(params?: { + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null { + if (!this.stagedRecoveryKey) { + return this.getRecoveryKeySummary(); + } + const staged = this.stagedRecoveryKey; + const privateKey = new Uint8Array(Buffer.from(staged.privateKeyBase64, "base64")); + const keyId = + typeof params?.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : staged.keyId; + this.saveRecoveryKeyToDisk({ + keyId, + keyInfo: params?.keyInfo ?? staged.keyInfo, + privateKey, + encodedPrivateKey: staged.encodedPrivateKey, + }); + this.clearStagedRecoveryKeyTracking(); + return this.getRecoveryKeySummary(); + } + + discardStagedRecoveryKey(): void { + for (const keyId of this.stagedCacheKeyIds) { + this.secretStorageKeyCache.delete(keyId); + } + this.clearStagedRecoveryKeyTracking(); + } + + async bootstrapSecretStorageWithRecoveryKey( + crypto: MatrixCryptoBootstrapApi, + options: { + setupNewKeyBackup?: boolean; + allowSecretStorageRecreateWithoutRecoveryKey?: boolean; + forceNewSecretStorage?: boolean; + } = {}, + ): Promise { + let status: MatrixSecretStorageStatus | null = null; + const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret + if (typeof getSecretStorageStatus === "function") { + try { + status = await getSecretStorageStatus.call(crypto); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err); + } + } + + const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId); + const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some( + (valid) => valid === false, + ); + let generatedRecoveryKey = false; + const storedRecovery = this.loadStoredRecoveryKey(); + const stagedRecovery = this.stagedRecoveryKey; + const sourceRecovery = stagedRecovery ?? storedRecovery; + let recoveryKey: MatrixGeneratedSecretStorageKey | null = sourceRecovery + ? { + keyInfo: sourceRecovery.keyInfo, + privateKey: new Uint8Array(Buffer.from(sourceRecovery.privateKeyBase64, "base64")), + encodedPrivateKey: sourceRecovery.encodedPrivateKey, + } + : null; + + if (recoveryKey && status?.defaultKeyId) { + const defaultKeyId = status.defaultKeyId; + this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo); + if (!stagedRecovery && storedRecovery && storedRecovery.keyId !== defaultKeyId) { + this.saveRecoveryKeyToDisk({ + keyId: defaultKeyId, + keyInfo: recoveryKey.keyInfo, + privateKey: recoveryKey.privateKey, + encodedPrivateKey: recoveryKey.encodedPrivateKey, + }); + } + } + + const ensureRecoveryKey = async (): Promise => { + if (recoveryKey) { + return recoveryKey; + } + if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") { + throw new Error( + "Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)", + ); + } + recoveryKey = await crypto.createRecoveryKeyFromPassphrase(); + this.saveRecoveryKeyToDisk(recoveryKey); + generatedRecoveryKey = true; + return recoveryKey; + }; + + const shouldRecreateSecretStorage = + options.forceNewSecretStorage === true || + !hasDefaultSecretStorageKey || + (!recoveryKey && status?.ready === false) || + hasKnownInvalidSecrets; + + if (hasKnownInvalidSecrets) { + // Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key. + recoveryKey = null; + } + + const secretStorageOptions: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + } = { + setupNewKeyBackup: options.setupNewKeyBackup === true, + }; + + if (shouldRecreateSecretStorage) { + secretStorageOptions.setupNewSecretStorage = true; + secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; + } + + try { + await crypto.bootstrapSecretStorage(secretStorageOptions); + } catch (err) { + const shouldRecreateWithoutRecoveryKey = + options.allowSecretStorageRecreateWithoutRecoveryKey === true && + hasDefaultSecretStorageKey && + isRepairableSecretStorageAccessError(err); + if (!shouldRecreateWithoutRecoveryKey) { + throw err; + } + + recoveryKey = null; + LogService.warn( + "MatrixClientLite", + "Secret storage exists on the server but local recovery material cannot unlock it; recreating secret storage during explicit bootstrap.", + ); + await crypto.bootstrapSecretStorage({ + setupNewSecretStorage: true, + setupNewKeyBackup: options.setupNewKeyBackup === true, + createSecretStorageKey: ensureRecoveryKey, + }); + } + + if (generatedRecoveryKey && this.recoveryKeyPath) { + LogService.warn( + "MatrixClientLite", + `Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`, + ); + } + } + + private clearStagedRecoveryKeyTracking(): void { + this.stagedRecoveryKey = null; + this.stagedCacheKeyIds.clear(); + } + + private rememberSecretStorageKey( + keyId: string, + key: Uint8Array, + keyInfo?: MatrixStoredRecoveryKey["keyInfo"], + ): void { + if (!keyId.trim()) { + return; + } + this.secretStorageKeyCache.set(keyId, { + key: new Uint8Array(key), + keyInfo, + }); + } + + private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null { + if (!this.recoveryKeyPath) { + return null; + } + try { + if (!fs.existsSync(this.recoveryKeyPath)) { + return null; + } + const raw = fs.readFileSync(this.recoveryKeyPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.privateKeyBase64 !== "string" || // pragma: allowlist secret + !parsed.privateKeyBase64.trim() + ) { + return null; + } + return { + version: 1, + createdAt: parsed.createdAt, + keyId: typeof parsed.keyId === "string" ? parsed.keyId : null, + encodedPrivateKey: + typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined, + privateKeyBase64: parsed.privateKeyBase64, + keyInfo: + parsed.keyInfo && typeof parsed.keyInfo === "object" + ? { + passphrase: parsed.keyInfo.passphrase, + name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined, + } + : undefined, + }; + } catch { + return null; + } + } + + private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void { + if (!this.recoveryKeyPath) { + return; + } + try { + const payload: MatrixStoredRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: typeof params.keyId === "string" ? params.keyId : null, + encodedPrivateKey: params.encodedPrivateKey, + privateKeyBase64: Buffer.from(params.privateKey).toString("base64"), + keyInfo: params.keyInfo + ? { + passphrase: params.keyInfo.passphrase, + name: params.keyInfo.name, + } + : undefined, + }; + fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true }); + fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8"); + fs.chmodSync(this.recoveryKeyPath, 0o600); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err); + } + } +} diff --git a/extensions/matrix/src/matrix/sdk/transport.test.ts b/extensions/matrix/src/matrix/sdk/transport.test.ts new file mode 100644 index 00000000000..51f9104ef61 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/transport.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { performMatrixRequest } from "./transport.js"; + +describe("performMatrixRequest", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects oversized raw responses before buffering the whole body", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("too-big", { + status: 200, + headers: { + "content-length": "8192", + }, + }), + ), + ); + + await expect( + performMatrixRequest({ + homeserver: "https://matrix.example.org", + accessToken: "token", + method: "GET", + endpoint: "/_matrix/media/v3/download/example/id", + timeoutMs: 5000, + raw: true, + maxBytes: 1024, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + }); + + it("applies streaming byte limits when raw responses omit content-length", async () => { + const chunk = new Uint8Array(768); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(stream, { + status: 200, + }), + ), + ); + + await expect( + performMatrixRequest({ + homeserver: "https://matrix.example.org", + accessToken: "token", + method: "GET", + endpoint: "/_matrix/media/v3/download/example/id", + timeoutMs: 5000, + raw: true, + maxBytes: 1024, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts new file mode 100644 index 00000000000..fc5d89e1d28 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/transport.ts @@ -0,0 +1,192 @@ +import { readResponseWithLimit } from "./read-response-with-limit.js"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +type QueryValue = + | string + | number + | boolean + | null + | undefined + | Array; + +export type QueryParams = Record | null | undefined; + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint) { + return "/"; + } + return endpoint.startsWith("/") ? endpoint : `/${endpoint}`; +} + +function applyQuery(url: URL, qs: QueryParams): void { + if (!qs) { + return; + } + for (const [key, rawValue] of Object.entries(qs)) { + if (rawValue === undefined || rawValue === null) { + continue; + } + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (item === undefined || item === null) { + continue; + } + url.searchParams.append(key, String(item)); + } + continue; + } + url.searchParams.set(key, String(rawValue)); + } +} + +function isRedirectStatus(statusCode: number): boolean { + return statusCode >= 300 && statusCode < 400; +} + +async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise { + let currentUrl = new URL(url.toString()); + let method = (init.method ?? "GET").toUpperCase(); + let body = init.body; + let headers = new Headers(init.headers ?? {}); + const maxRedirects = 5; + + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { + const response = await fetch(currentUrl, { + ...init, + method, + body, + headers, + redirect: "manual", + }); + + if (!isRedirectStatus(response.status)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`); + } + + const nextUrl = new URL(location, currentUrl); + if (nextUrl.protocol !== currentUrl.protocol) { + throw new Error( + `Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`, + ); + } + + if (nextUrl.origin !== currentUrl.origin) { + headers = new Headers(headers); + headers.delete("authorization"); + } + + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && + method !== "GET" && + method !== "HEAD") + ) { + method = "GET"; + body = undefined; + headers = new Headers(headers); + headers.delete("content-type"); + headers.delete("content-length"); + } + + currentUrl = nextUrl; + } + + throw new Error(`Too many redirects while requesting ${url.toString()}`); +} + +export async function performMatrixRequest(params: { + homeserver: string; + accessToken: string; + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + body?: unknown; + timeoutMs: number; + raw?: boolean; + maxBytes?: number; + readIdleTimeoutMs?: number; + allowAbsoluteEndpoint?: boolean; +}): Promise<{ response: Response; text: string; buffer: Buffer }> { + const isAbsoluteEndpoint = + params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://"); + if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) { + throw new Error( + `Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`, + ); + } + + const baseUrl = isAbsoluteEndpoint + ? new URL(params.endpoint) + : new URL(normalizeEndpoint(params.endpoint), params.homeserver); + applyQuery(baseUrl, params.qs); + + const headers = new Headers(); + headers.set("Accept", params.raw ? "*/*" : "application/json"); + if (params.accessToken) { + headers.set("Authorization", `Bearer ${params.accessToken}`); + } + + let body: BodyInit | undefined; + if (params.body !== undefined) { + if ( + params.body instanceof Uint8Array || + params.body instanceof ArrayBuffer || + typeof params.body === "string" + ) { + body = params.body as BodyInit; + } else { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(params.body); + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + try { + const response = await fetchWithSafeRedirects(baseUrl, { + method: params.method, + headers, + body, + signal: controller.signal, + }); + if (params.raw) { + const contentLength = response.headers.get("content-length"); + if (params.maxBytes && contentLength) { + const length = Number(contentLength); + if (Number.isFinite(length) && length > params.maxBytes) { + throw new Error( + `Matrix media exceeds configured size limit (${length} bytes > ${params.maxBytes} bytes)`, + ); + } + } + const bytes = params.maxBytes + ? await readResponseWithLimit(response, params.maxBytes, { + onOverflow: ({ maxBytes, size }) => + new Error( + `Matrix media exceeds configured size limit (${size} bytes > ${maxBytes} bytes)`, + ), + chunkTimeoutMs: params.readIdleTimeoutMs, + }) + : Buffer.from(await response.arrayBuffer()); + return { + response, + text: bytes.toString("utf8"), + buffer: bytes, + }; + } + const text = await response.text(); + return { + response, + text, + buffer: Buffer.from(text, "utf8"), + }; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/extensions/matrix/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts new file mode 100644 index 00000000000..d8e21110869 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/types.ts @@ -0,0 +1,232 @@ +import type { + MatrixVerificationRequestLike, + MatrixVerificationSummary, +} from "./verification-manager.js"; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; + state_key?: string; +}; + +export type MatrixRelationsPage = { + originalEvent?: MatrixRawEvent | null; + events: MatrixRawEvent[]; + nextBatch?: string | null; + prevBatch?: string | null; +}; + +export type MatrixClientEventMap = { + "room.event": [roomId: string, event: MatrixRawEvent]; + "room.message": [roomId: string, event: MatrixRawEvent]; + "room.encrypted_event": [roomId: string, event: MatrixRawEvent]; + "room.decrypted_event": [roomId: string, event: MatrixRawEvent]; + "room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error]; + "room.invite": [roomId: string, event: MatrixRawEvent]; + "room.join": [roomId: string, event: MatrixRawEvent]; + "verification.summary": [summary: MatrixVerificationSummary]; +}; + +export type EncryptedFile = { + url: string; + key: { + kty: string; + key_ops: string[]; + alg: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: Record; + v: string; +}; + +export type FileWithThumbnailInfo = { + size?: number; + mimetype?: string; + thumbnail_url?: string; + thumbnail_info?: { + w?: number; + h?: number; + mimetype?: string; + size?: number; + }; +}; + +export type DimensionalFileInfo = FileWithThumbnailInfo & { + w?: number; + h?: number; +}; + +export type TimedFileInfo = FileWithThumbnailInfo & { + duration?: number; +}; + +export type VideoFileInfo = DimensionalFileInfo & + TimedFileInfo & { + duration?: number; + }; + +export type MessageEventContent = { + msgtype?: string; + body?: string; + format?: string; + formatted_body?: string; + filename?: string; + url?: string; + file?: EncryptedFile; + info?: Record; + "m.relates_to"?: Record; + "m.new_content"?: unknown; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; + [key: string]: unknown; +}; + +export type TextualMessageEventContent = MessageEventContent & { + msgtype: string; + body: string; +}; + +export type LocationMessageEventContent = MessageEventContent & { + msgtype?: string; + geo_uri?: string; +}; + +export type MatrixSecretStorageStatus = { + ready: boolean; + defaultKeyId: string | null; + secretStorageKeyValidityMap?: Record; +}; + +export type MatrixGeneratedSecretStorageKey = { + keyId?: string | null; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; + privateKey: Uint8Array; + encodedPrivateKey?: string; +}; + +export type MatrixDeviceVerificationStatusLike = { + isVerified?: () => boolean; + localVerified?: boolean; + crossSigningVerified?: boolean; + signedByOwner?: boolean; +}; + +export type MatrixKeyBackupInfo = { + algorithm: string; + auth_data: Record; + count?: number; + etag?: string; + version?: string; +}; + +export type MatrixKeyBackupTrustInfo = { + trusted: boolean; + matchesDecryptionKey: boolean; +}; + +export type MatrixRoomKeyBackupRestoreResult = { + total: number; + imported: number; +}; + +export type MatrixImportRoomKeyProgress = { + stage: string; + successes?: number; + failures?: number; + total?: number; +}; + +export type MatrixSecretStorageKeyDescription = { + passphrase?: unknown; + name?: string; + [key: string]: unknown; +}; + +export type MatrixCryptoCallbacks = { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + cacheSecretStorageKey?: ( + keyId: string, + keyInfo: MatrixSecretStorageKeyDescription, + key: Uint8Array, + ) => void; +}; + +export type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; +}; + +export type MatrixAuthDict = Record; + +export type MatrixUiAuthCallback = ( + makeRequest: (authData: MatrixAuthDict | null) => Promise, +) => Promise; + +export type MatrixCryptoBootstrapApi = { + on: (eventName: string, listener: (...args: unknown[]) => void) => void; + bootstrapCrossSigning: (opts: { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?: MatrixUiAuthCallback; + }) => Promise; + bootstrapSecretStorage: (opts?: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + }) => Promise; + createRecoveryKeyFromPassphrase?: (password?: string) => Promise; + getSecretStorageStatus?: () => Promise; + requestOwnUserVerification: () => Promise; + findVerificationRequestDMInProgress?: ( + roomId: string, + userId: string, + ) => MatrixVerificationRequestLike | undefined; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; + getDeviceVerificationStatus?: ( + userId: string, + deviceId: string, + ) => Promise; + getSessionBackupPrivateKey?: () => Promise; + loadSessionBackupPrivateKeyFromSecretStorage?: () => Promise; + getActiveSessionBackupVersion?: () => Promise; + getKeyBackupInfo?: () => Promise; + isKeyBackupTrusted?: (info: MatrixKeyBackupInfo) => Promise; + checkKeyBackupAndEnable?: () => Promise; + restoreKeyBackup?: (opts?: { + progressCallback?: (progress: MatrixImportRoomKeyProgress) => void; + }) => Promise; + setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; + crossSignDevice?: (deviceId: string) => Promise; + isCrossSigningReady?: () => Promise; + userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; +}; diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts new file mode 100644 index 00000000000..c9dfa068d69 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts @@ -0,0 +1,508 @@ +import { EventEmitter } from "node:events"; +import { + VerificationPhase, + VerificationRequestEvent, +} from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { describe, expect, it, vi } from "vitest"; +import { + MatrixVerificationManager, + type MatrixShowQrCodeCallbacks, + type MatrixShowSasCallbacks, + type MatrixVerificationRequestLike, + type MatrixVerifierLike, +} from "./verification-manager.js"; + +class MockVerifier extends EventEmitter implements MatrixVerifierLike { + constructor( + private readonly sasCallbacks: MatrixShowSasCallbacks | null, + private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null, + private readonly verifyImpl: () => Promise = async () => {}, + ) { + super(); + } + + verify(): Promise { + return this.verifyImpl(); + } + + cancel(_e: Error): void { + void _e; + } + + getShowSasCallbacks(): MatrixShowSasCallbacks | null { + return this.sasCallbacks; + } + + getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null { + return this.qrCallbacks; + } +} + +class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike { + transactionId?: string; + roomId?: string; + initiatedByMe = false; + otherUserId = "@alice:example.org"; + otherDeviceId?: string; + isSelfVerification = false; + phase = VerificationPhase.Requested; + pending = true; + accepting = false; + declining = false; + methods: string[] = ["m.sas.v1"]; + chosenMethod?: string | null; + cancellationCode?: string | null; + verifier?: MatrixVerifierLike; + + constructor(init?: Partial) { + super(); + Object.assign(this, init); + } + + accept = vi.fn(async () => { + this.phase = VerificationPhase.Ready; + }); + + cancel = vi.fn(async () => { + this.phase = VerificationPhase.Cancelled; + }); + + startVerification = vi.fn(async (_method: string) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3])); +} + +describe("MatrixVerificationManager", () => { + it("handles rust verification requests whose methods getter throws", () => { + const manager = new MatrixVerificationManager(); + const request = new MockVerificationRequest({ + transactionId: "txn-rust-methods", + phase: VerificationPhase.Requested, + initiatedByMe: true, + }); + Object.defineProperty(request, "methods", { + get() { + throw new Error("not implemented"); + }, + }); + + const summary = manager.trackVerificationRequest(request); + + expect(summary.id).toBeTruthy(); + expect(summary.methods).toEqual([]); + expect(summary.phaseName).toBe("requested"); + }); + + it("reuses the same tracked id for repeated transaction IDs", () => { + const manager = new MatrixVerificationManager(); + const first = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Requested, + }); + const second = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Ready, + pending: false, + chosenMethod: "m.sas.v1", + }); + + const firstSummary = manager.trackVerificationRequest(first); + const secondSummary = manager.trackVerificationRequest(second); + + expect(secondSummary.id).toBe(firstSummary.id); + expect(secondSummary.phase).toBe(VerificationPhase.Ready); + expect(secondSummary.pending).toBe(false); + expect(secondSummary.chosenMethod).toBe("m.sas.v1"); + }); + + it("starts SAS verification and exposes SAS payload/callback flow", async () => { + const confirm = vi.fn(async () => {}); + const mismatch = vi.fn(); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "cat"], + ["dog", "dog"], + ["fox", "fox"], + ], + }, + confirm, + mismatch, + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-2", + verifier, + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + const started = await manager.startVerification(tracked.id, "sas"); + expect(started.hasSas).toBe(true); + expect(started.sas?.decimal).toEqual([111, 222, 333]); + expect(started.sas?.emoji?.length).toBe(3); + + const sas = manager.getVerificationSas(tracked.id); + expect(sas.decimal).toEqual([111, 222, 333]); + expect(sas.emoji?.length).toBe(3); + + await manager.confirmVerificationSas(tracked.id); + expect(confirm).toHaveBeenCalledTimes(1); + + manager.mismatchVerificationSas(tracked.id); + expect(mismatch).toHaveBeenCalledTimes(1); + }); + + it("auto-starts an incoming verifier exposed via request change events", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-incoming-change", + verifier: undefined, + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + request.verifier = verifier; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect(verify).toHaveBeenCalledTimes(1); + }); + const summary = manager.listVerifications().find((item) => item.id === tracked.id); + expect(summary?.hasSas).toBe(true); + expect(summary?.sas?.decimal).toEqual([6158, 1986, 3513]); + expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]); + }); + + it("emits summary updates when SAS becomes available", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-summary-listener", + roomId: "!dm:example.org", + verifier: undefined, + }); + const manager = new MatrixVerificationManager(); + const summaries: ReturnType = []; + manager.onSummaryChanged((summary) => { + summaries.push(summary); + }); + + manager.trackVerificationRequest(request); + request.verifier = verifier; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect( + summaries.some( + (summary) => + summary.transactionId === "txn-summary-listener" && + summary.roomId === "!dm:example.org" && + summary.hasSas, + ), + ).toBe(true); + }); + }); + + it("does not auto-start non-self inbound SAS when request becomes ready without a verifier", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [1234, 5678, 9012], + emoji: [ + ["gift", "Gift"], + ["rocket", "Rocket"], + ["butterfly", "Butterfly"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-no-auto-start-dm-sas", + initiatedByMe: false, + isSelfVerification: false, + verifier: undefined, + }); + request.startVerification = vi.fn(async (_method: string) => { + request.phase = VerificationPhase.Started; + request.verifier = verifier; + return verifier; + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + request.phase = VerificationPhase.Ready; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect(manager.listVerifications().find((item) => item.id === tracked.id)?.phase).toBe( + VerificationPhase.Ready, + ); + }); + expect(request.startVerification).not.toHaveBeenCalled(); + expect(verify).not.toHaveBeenCalled(); + expect(manager.listVerifications().find((item) => item.id === tracked.id)?.hasSas).toBe(false); + }); + + it("auto-starts self verification SAS when request becomes ready without a verifier", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [1234, 5678, 9012], + emoji: [ + ["gift", "Gift"], + ["rocket", "Rocket"], + ["butterfly", "Butterfly"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-auto-start-self-sas", + initiatedByMe: false, + isSelfVerification: true, + verifier: undefined, + }); + request.startVerification = vi.fn(async (_method: string) => { + request.phase = VerificationPhase.Started; + request.verifier = verifier; + return verifier; + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + request.phase = VerificationPhase.Ready; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect(request.startVerification).toHaveBeenCalledWith("m.sas.v1"); + }); + await vi.waitFor(() => { + expect(verify).toHaveBeenCalledTimes(1); + }); + const summary = manager.listVerifications().find((item) => item.id === tracked.id); + expect(summary?.hasSas).toBe(true); + expect(summary?.sas?.decimal).toEqual([1234, 5678, 9012]); + expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]); + }); + + it("auto-accepts incoming verification requests only once per transaction", async () => { + const request = new MockVerificationRequest({ + transactionId: "txn-auto-accept-once", + initiatedByMe: false, + isSelfVerification: false, + phase: VerificationPhase.Requested, + accepting: false, + declining: false, + }); + const manager = new MatrixVerificationManager(); + + manager.trackVerificationRequest(request); + request.emit(VerificationRequestEvent.Change); + manager.trackVerificationRequest(request); + + await vi.waitFor(() => { + expect(request.accept).toHaveBeenCalledTimes(1); + }); + }); + + it("auto-confirms inbound SAS after a human-safe delay", async () => { + vi.useFakeTimers(); + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-auto-confirm", + initiatedByMe: false, + verifier, + }); + try { + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await vi.advanceTimersByTimeAsync(29_000); + expect(confirm).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_100); + expect(confirm).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("does not auto-confirm SAS for verifications initiated by this device", async () => { + vi.useFakeTimers(); + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "Cat"], + ["dog", "Dog"], + ["fox", "Fox"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-no-auto-confirm", + initiatedByMe: true, + verifier, + }); + try { + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await vi.advanceTimersByTimeAsync(20); + expect(confirm).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a pending auto-confirm when SAS is explicitly mismatched", async () => { + vi.useFakeTimers(); + const confirm = vi.fn(async () => {}); + const mismatch = vi.fn(); + const verifier = new MockVerifier( + { + sas: { + decimal: [444, 555, 666], + emoji: [ + ["panda", "Panda"], + ["rocket", "Rocket"], + ["crown", "Crown"], + ], + }, + confirm, + mismatch, + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-mismatch-cancels-auto-confirm", + initiatedByMe: false, + verifier, + }); + try { + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + manager.mismatchVerificationSas(tracked.id); + await vi.advanceTimersByTimeAsync(2000); + + expect(mismatch).toHaveBeenCalledTimes(1); + expect(confirm).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("prunes stale terminal sessions during list operations", () => { + const now = new Date("2026-02-08T15:00:00.000Z").getTime(); + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValue(now); + + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest( + new MockVerificationRequest({ + transactionId: "txn-old-done", + phase: VerificationPhase.Done, + pending: false, + }), + ); + + nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1); + const summaries = manager.listVerifications(); + + expect(summaries).toHaveLength(0); + nowSpy.mockRestore(); + }); +}); diff --git a/extensions/matrix/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts new file mode 100644 index 00000000000..ac60618d903 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-manager.ts @@ -0,0 +1,677 @@ +import { + VerificationPhase, + VerificationRequestEvent, + VerifierEvent, +} from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; + +export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr"; + +export type MatrixVerificationSummary = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + initiatedByMe: boolean; + phase: number; + phaseName: string; + pending: boolean; + methods: string[]; + chosenMethod?: string | null; + canAccept: boolean; + hasSas: boolean; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + hasReciprocateQr: boolean; + completed: boolean; + error?: string; + createdAt: string; + updatedAt: string; +}; + +type MatrixVerificationSummaryListener = (summary: MatrixVerificationSummary) => void; + +export type MatrixShowSasCallbacks = { + sas: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + confirm: () => Promise; + mismatch: () => void; + cancel: () => void; +}; + +export type MatrixShowQrCodeCallbacks = { + confirm: () => void; + cancel: () => void; +}; + +export type MatrixVerifierLike = { + verify: () => Promise; + cancel: (e: Error) => void; + getShowSasCallbacks: () => MatrixShowSasCallbacks | null; + getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationRequestLike = { + transactionId?: string; + roomId?: string; + initiatedByMe: boolean; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + phase: number; + pending: boolean; + accepting: boolean; + declining: boolean; + methods: string[]; + chosenMethod?: string | null; + cancellationCode?: string | null; + accept: () => Promise; + cancel: (params?: { reason?: string; code?: string }) => Promise; + startVerification: (method: string) => Promise; + scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise; + generateQRCode: () => Promise; + verifier?: MatrixVerifierLike; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationCryptoApi = { + requestOwnUserVerification: () => Promise; + findVerificationRequestDMInProgress?: ( + roomId: string, + userId: string, + ) => MatrixVerificationRequestLike | undefined; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; +}; + +type MatrixVerificationSession = { + id: string; + request: MatrixVerificationRequestLike; + createdAtMs: number; + updatedAtMs: number; + error?: string; + activeVerifier?: MatrixVerifierLike; + verifyPromise?: Promise; + verifyStarted: boolean; + startRequested: boolean; + acceptRequested: boolean; + sasAutoConfirmStarted: boolean; + sasAutoConfirmTimer?: ReturnType; + sasCallbacks?: MatrixShowSasCallbacks; + reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; +}; + +const MAX_TRACKED_VERIFICATION_SESSIONS = 256; +const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000; +const SAS_AUTO_CONFIRM_DELAY_MS = 30_000; + +export class MatrixVerificationManager { + private readonly verificationSessions = new Map(); + private verificationSessionCounter = 0; + private readonly trackedVerificationRequests = new WeakSet(); + private readonly trackedVerificationVerifiers = new WeakSet(); + private readonly summaryListeners = new Set(); + + private readRequestValue( + request: MatrixVerificationRequestLike, + reader: () => T, + fallback: T, + ): T { + try { + return reader(); + } catch { + return fallback; + } + } + + private pruneVerificationSessions(nowMs: number): void { + for (const [id, session] of this.verificationSessions) { + const phase = this.readRequestValue(session.request, () => session.request.phase, -1); + const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled; + if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) { + this.verificationSessions.delete(id); + } + } + + if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) { + return; + } + + const sortedByAge = Array.from(this.verificationSessions.entries()).sort( + (a, b) => a[1].updatedAtMs - b[1].updatedAtMs, + ); + const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS; + for (let i = 0; i < overflow; i += 1) { + const entry = sortedByAge[i]; + if (entry) { + this.verificationSessions.delete(entry[0]); + } + } + } + + private getVerificationPhaseName(phase: number): string { + switch (phase) { + case VerificationPhase.Unsent: + return "unsent"; + case VerificationPhase.Requested: + return "requested"; + case VerificationPhase.Ready: + return "ready"; + case VerificationPhase.Started: + return "started"; + case VerificationPhase.Cancelled: + return "cancelled"; + case VerificationPhase.Done: + return "done"; + default: + return `unknown(${phase})`; + } + } + + private emitVerificationSummary(session: MatrixVerificationSession): void { + const summary = this.buildVerificationSummary(session); + for (const listener of this.summaryListeners) { + listener(summary); + } + } + + private touchVerificationSession(session: MatrixVerificationSession): void { + session.updatedAtMs = Date.now(); + this.emitVerificationSummary(session); + } + + private clearSasAutoConfirmTimer(session: MatrixVerificationSession): void { + if (!session.sasAutoConfirmTimer) { + return; + } + clearTimeout(session.sasAutoConfirmTimer); + session.sasAutoConfirmTimer = undefined; + } + + private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary { + const request = session.request; + const phase = this.readRequestValue(request, () => request.phase, VerificationPhase.Requested); + const accepting = this.readRequestValue(request, () => request.accepting, false); + const declining = this.readRequestValue(request, () => request.declining, false); + const pending = this.readRequestValue(request, () => request.pending, false); + const methodsRaw = this.readRequestValue(request, () => request.methods, []); + const methods = Array.isArray(methodsRaw) + ? methodsRaw.filter((entry): entry is string => typeof entry === "string") + : []; + const sasCallbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (sasCallbacks) { + session.sasCallbacks = sasCallbacks; + } + const canAccept = phase < VerificationPhase.Ready && !accepting && !declining; + return { + id: session.id, + transactionId: this.readRequestValue(request, () => request.transactionId, undefined), + roomId: this.readRequestValue(request, () => request.roomId, undefined), + otherUserId: this.readRequestValue(request, () => request.otherUserId, "unknown"), + otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId, undefined), + isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false), + initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false), + phase, + phaseName: this.getVerificationPhaseName(phase), + pending, + methods, + chosenMethod: this.readRequestValue(request, () => request.chosenMethod ?? null, null), + canAccept, + hasSas: Boolean(sasCallbacks), + sas: sasCallbacks + ? { + decimal: sasCallbacks.sas.decimal, + emoji: sasCallbacks.sas.emoji, + } + : undefined, + hasReciprocateQr: Boolean(session.reciprocateQrCallbacks), + completed: phase === VerificationPhase.Done, + error: session.error, + createdAt: new Date(session.createdAtMs).toISOString(), + updatedAt: new Date(session.updatedAtMs).toISOString(), + }; + } + + private findVerificationSession(id: string): MatrixVerificationSession { + const direct = this.verificationSessions.get(id); + if (direct) { + return direct; + } + for (const session of this.verificationSessions.values()) { + const txId = this.readRequestValue(session.request, () => session.request.transactionId, ""); + if (txId === id) { + return session; + } + } + throw new Error(`Matrix verification request not found: ${id}`); + } + + private ensureVerificationRequestTracked(session: MatrixVerificationSession): void { + const requestObj = session.request as unknown as object; + if (this.trackedVerificationRequests.has(requestObj)) { + return; + } + this.trackedVerificationRequests.add(requestObj); + session.request.on(VerificationRequestEvent.Change, () => { + this.touchVerificationSession(session); + this.maybeAutoAcceptInboundRequest(session); + const verifier = this.readRequestValue(session.request, () => session.request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(session, verifier); + } + this.maybeAutoStartInboundSas(session); + }); + } + + private maybeAutoAcceptInboundRequest(session: MatrixVerificationSession): void { + if (session.acceptRequested) { + return; + } + const request = session.request; + const isSelfVerification = this.readRequestValue( + request, + () => request.isSelfVerification, + false, + ); + const initiatedByMe = this.readRequestValue(request, () => request.initiatedByMe, false); + const phase = this.readRequestValue(request, () => request.phase, VerificationPhase.Requested); + const accepting = this.readRequestValue(request, () => request.accepting, false); + const declining = this.readRequestValue(request, () => request.declining, false); + if (isSelfVerification || initiatedByMe) { + return; + } + if (phase !== VerificationPhase.Requested || accepting || declining) { + return; + } + + session.acceptRequested = true; + void request + .accept() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.acceptRequested = false; + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + private maybeAutoStartInboundSas(session: MatrixVerificationSession): void { + if (session.activeVerifier || session.verifyStarted || session.startRequested) { + return; + } + if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) { + return; + } + if (!this.readRequestValue(session.request, () => session.request.isSelfVerification, false)) { + return; + } + const phase = this.readRequestValue( + session.request, + () => session.request.phase, + VerificationPhase.Requested, + ); + if (phase < VerificationPhase.Ready || phase >= VerificationPhase.Cancelled) { + return; + } + const methodsRaw = this.readRequestValue( + session.request, + () => session.request.methods, + [], + ); + const methods = Array.isArray(methodsRaw) + ? methodsRaw.filter((entry): entry is string => typeof entry === "string") + : []; + const chosenMethod = this.readRequestValue( + session.request, + () => session.request.chosenMethod, + null, + ); + const supportsSas = + methods.includes(VerificationMethod.Sas) || chosenMethod === VerificationMethod.Sas; + if (!supportsSas) { + return; + } + + session.startRequested = true; + void session.request + .startVerification(VerificationMethod.Sas) + .then((verifier) => { + this.attachVerifierToVerificationSession(session, verifier); + this.touchVerificationSession(session); + }) + .catch(() => { + session.startRequested = false; + }); + } + + private attachVerifierToVerificationSession( + session: MatrixVerificationSession, + verifier: MatrixVerifierLike, + ): void { + session.activeVerifier = verifier; + this.touchVerificationSession(session); + + const maybeSas = verifier.getShowSasCallbacks(); + if (maybeSas) { + session.sasCallbacks = maybeSas; + this.maybeAutoConfirmSas(session); + } + const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); + if (maybeReciprocateQr) { + session.reciprocateQrCallbacks = maybeReciprocateQr; + } + + const verifierObj = verifier as unknown as object; + if (this.trackedVerificationVerifiers.has(verifierObj)) { + this.ensureVerificationStarted(session); + return; + } + this.trackedVerificationVerifiers.add(verifierObj); + + verifier.on(VerifierEvent.ShowSas, (sas) => { + session.sasCallbacks = sas as MatrixShowSasCallbacks; + this.touchVerificationSession(session); + this.maybeAutoConfirmSas(session); + }); + verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => { + session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks; + this.touchVerificationSession(session); + }); + verifier.on(VerifierEvent.Cancel, (err) => { + this.clearSasAutoConfirmTimer(session); + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + this.ensureVerificationStarted(session); + } + + private maybeAutoConfirmSas(session: MatrixVerificationSession): void { + if (session.sasAutoConfirmStarted || session.sasAutoConfirmTimer) { + return; + } + if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) { + return; + } + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + return; + } + session.sasCallbacks = callbacks; + // Give the remote client a moment to surface the compare-emoji UI before + // we send our MAC and finish our side of the SAS flow. + session.sasAutoConfirmTimer = setTimeout(() => { + session.sasAutoConfirmTimer = undefined; + const phase = this.readRequestValue( + session.request, + () => session.request.phase, + VerificationPhase.Requested, + ); + if (phase >= VerificationPhase.Cancelled) { + return; + } + session.sasAutoConfirmStarted = true; + void callbacks + .confirm() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + }, SAS_AUTO_CONFIRM_DELAY_MS); + } + + private ensureVerificationStarted(session: MatrixVerificationSession): void { + if (!session.activeVerifier || session.verifyStarted) { + return; + } + session.verifyStarted = true; + const verifier = session.activeVerifier; + session.verifyPromise = verifier + .verify() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + onSummaryChanged(listener: MatrixVerificationSummaryListener): () => void { + this.summaryListeners.add(listener); + return () => { + this.summaryListeners.delete(listener); + }; + } + + trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { + this.pruneVerificationSessions(Date.now()); + const txId = this.readRequestValue(request, () => request.transactionId?.trim(), ""); + if (txId) { + for (const existing of this.verificationSessions.values()) { + const existingTxId = this.readRequestValue( + existing.request, + () => existing.request.transactionId, + "", + ); + if (existingTxId === txId) { + existing.request = request; + this.ensureVerificationRequestTracked(existing); + const verifier = this.readRequestValue(request, () => request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(existing, verifier); + } + this.touchVerificationSession(existing); + return this.buildVerificationSummary(existing); + } + } + } + + const now = Date.now(); + const id = `verification-${++this.verificationSessionCounter}`; + const session: MatrixVerificationSession = { + id, + request, + createdAtMs: now, + updatedAtMs: now, + verifyStarted: false, + startRequested: false, + acceptRequested: false, + sasAutoConfirmStarted: false, + }; + this.verificationSessions.set(session.id, session); + this.ensureVerificationRequestTracked(session); + this.maybeAutoAcceptInboundRequest(session); + const verifier = this.readRequestValue(request, () => request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(session, verifier); + } + this.maybeAutoStartInboundSas(session); + this.emitVerificationSummary(session); + return this.buildVerificationSummary(session); + } + + async requestOwnUserVerification( + crypto: MatrixVerificationCryptoApi | undefined, + ): Promise { + if (!crypto) { + return null; + } + const request = + (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + if (!request) { + return null; + } + return this.trackVerificationRequest(request); + } + + listVerifications(): MatrixVerificationSummary[] { + this.pruneVerificationSessions(Date.now()); + const summaries = Array.from(this.verificationSessions.values()).map((session) => + this.buildVerificationSummary(session), + ); + return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + async requestVerification( + crypto: MatrixVerificationCryptoApi | undefined, + params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }, + ): Promise { + if (!crypto) { + throw new Error("Matrix crypto is not available"); + } + let request: MatrixVerificationRequestLike | null = null; + if (params.ownUser) { + request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + } else if (params.userId && params.deviceId && crypto.requestDeviceVerification) { + request = await crypto.requestDeviceVerification(params.userId, params.deviceId); + } else if (params.userId && params.roomId && crypto.requestVerificationDM) { + request = await crypto.requestVerificationDM(params.userId, params.roomId); + } else { + throw new Error( + "Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId", + ); + } + + if (!request) { + throw new Error("Matrix verification request could not be created"); + } + return this.trackVerificationRequest(request); + } + + async acceptVerification(id: string): Promise { + const session = this.findVerificationSession(id); + await session.request.accept(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async cancelVerification( + id: string, + params?: { reason?: string; code?: string }, + ): Promise { + const session = this.findVerificationSession(id); + await session.request.cancel(params); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async startVerification( + id: string, + method: MatrixVerificationMethod = "sas", + ): Promise { + const session = this.findVerificationSession(id); + if (method !== "sas") { + throw new Error("Matrix startVerification currently supports only SAS directly"); + } + const verifier = await session.request.startVerification(VerificationMethod.Sas); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> { + const session = this.findVerificationSession(id); + const qr = await session.request.generateQRCode(); + if (!qr) { + throw new Error("Matrix verification QR data is not available yet"); + } + return { qrDataBase64: Buffer.from(qr).toString("base64") }; + } + + async scanVerificationQr(id: string, qrDataBase64: string): Promise { + const session = this.findVerificationSession(id); + const trimmed = qrDataBase64.trim(); + if (!trimmed) { + throw new Error("Matrix verification QR payload is required"); + } + const qrBytes = Buffer.from(trimmed, "base64"); + if (qrBytes.length === 0) { + throw new Error("Matrix verification QR payload is invalid base64"); + } + const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes)); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async confirmVerificationSas(id: string): Promise { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS confirmation is not available for this verification request"); + } + this.clearSasAutoConfirmTimer(session); + session.sasCallbacks = callbacks; + session.sasAutoConfirmStarted = true; + await callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + mismatchVerificationSas(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS mismatch is not available for this verification request"); + } + this.clearSasAutoConfirmTimer(session); + session.sasCallbacks = callbacks; + callbacks.mismatch(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = + session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks(); + if (!callbacks) { + throw new Error( + "Matrix reciprocate-QR confirmation is not available for this verification request", + ); + } + session.reciprocateQrCallbacks = callbacks; + callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + getVerificationSas(id: string): { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + } { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS data is not available for this verification request"); + } + session.sasCallbacks = callbacks; + return { + decimal: callbacks.sas.decimal, + emoji: callbacks.sas.emoji, + }; + } +} diff --git a/extensions/matrix/src/matrix/sdk/verification-status.ts b/extensions/matrix/src/matrix/sdk/verification-status.ts new file mode 100644 index 00000000000..e6de1906a75 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-status.ts @@ -0,0 +1,23 @@ +import type { MatrixDeviceVerificationStatusLike } from "./types.js"; + +export function isMatrixDeviceLocallyVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return status?.localVerified === true; +} + +export function isMatrixDeviceOwnerVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return status?.crossSigningVerified === true || status?.signedByOwner === true; +} + +export function isMatrixDeviceVerifiedInCurrentClient( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return ( + status?.isVerified?.() === true || + isMatrixDeviceLocallyVerified(status) || + isMatrixDeviceOwnerVerified(status) + ); +} diff --git a/extensions/matrix/src/matrix/send-queue.test.ts b/extensions/matrix/src/matrix/send-queue.test.ts deleted file mode 100644 index c85981697a0..00000000000 --- a/extensions/matrix/src/matrix/send-queue.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js"; - -describe("enqueueSend", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("serializes sends per room", async () => { - const gate = createDeferred(); - const events: string[] = []; - - const first = enqueueSend("!room:example.org", async () => { - events.push("start1"); - await gate.promise; - events.push("end1"); - return "one"; - }); - const second = enqueueSend("!room:example.org", async () => { - events.push("start2"); - events.push("end2"); - return "two"; - }); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - expect(events).toEqual(["start1"]); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2); - expect(events).toEqual(["start1"]); - - gate.resolve(); - await first; - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1); - expect(events).toEqual(["start1", "end1"]); - await vi.advanceTimersByTimeAsync(1); - await second; - expect(events).toEqual(["start1", "end1", "start2", "end2"]); - }); - - it("does not serialize across different rooms", async () => { - const events: string[] = []; - - const a = enqueueSend("!a:example.org", async () => { - events.push("a"); - return "a"; - }); - const b = enqueueSend("!b:example.org", async () => { - events.push("b"); - return "b"; - }); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - await Promise.all([a, b]); - expect(events.sort()).toEqual(["a", "b"]); - }); - - it("continues queue after failures", async () => { - const first = enqueueSend("!room:example.org", async () => { - throw new Error("boom"); - }).then( - () => ({ ok: true as const }), - (error) => ({ ok: false as const, error }), - ); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - const firstResult = await first; - expect(firstResult.ok).toBe(false); - if (firstResult.ok) { - throw new Error("expected first queue item to fail"); - } - expect(firstResult.error).toBeInstanceOf(Error); - expect(firstResult.error.message).toBe("boom"); - - const second = enqueueSend("!room:example.org", async () => "ok"); - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - await expect(second).resolves.toBe("ok"); - }); - - it("continues queued work when the head task fails", async () => { - const gate = createDeferred(); - const events: string[] = []; - - const first = enqueueSend("!room:example.org", async () => { - events.push("start1"); - await gate.promise; - throw new Error("boom"); - }).then( - () => ({ ok: true as const }), - (error) => ({ ok: false as const, error }), - ); - const second = enqueueSend("!room:example.org", async () => { - events.push("start2"); - return "two"; - }); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - expect(events).toEqual(["start1"]); - - gate.resolve(); - const firstResult = await first; - expect(firstResult.ok).toBe(false); - if (firstResult.ok) { - throw new Error("expected head queue item to fail"); - } - expect(firstResult.error).toBeInstanceOf(Error); - - await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS); - await expect(second).resolves.toBe("two"); - expect(events).toEqual(["start1", "start2"]); - }); - - it("supports custom gap and delay injection", async () => { - const events: string[] = []; - const delayFn = vi.fn(async (_ms: number) => {}); - - const first = enqueueSend( - "!room:example.org", - async () => { - events.push("first"); - return "one"; - }, - { gapMs: 7, delayFn }, - ); - const second = enqueueSend( - "!room:example.org", - async () => { - events.push("second"); - return "two"; - }, - { gapMs: 7, delayFn }, - ); - - await expect(first).resolves.toBe("one"); - await expect(second).resolves.toBe("two"); - expect(events).toEqual(["first", "second"]); - expect(delayFn).toHaveBeenCalledTimes(2); - expect(delayFn).toHaveBeenNthCalledWith(1, 7); - expect(delayFn).toHaveBeenNthCalledWith(2, 7); - }); -}); diff --git a/extensions/matrix/src/matrix/send-queue.ts b/extensions/matrix/src/matrix/send-queue.ts deleted file mode 100644 index 4bad4878f90..00000000000 --- a/extensions/matrix/src/matrix/send-queue.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; - -export const DEFAULT_SEND_GAP_MS = 150; - -type MatrixSendQueueOptions = { - gapMs?: number; - delayFn?: (ms: number) => Promise; -}; - -// Serialize sends per room to preserve Matrix delivery order. -const roomQueues = new KeyedAsyncQueue(); - -export function enqueueSend( - roomId: string, - fn: () => Promise, - options?: MatrixSendQueueOptions, -): Promise { - const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS; - const delayFn = options?.delayFn ?? delay; - return roomQueues.enqueue(roomId, async () => { - await delayFn(gapMs); - return await fn(); - }); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 3833113a981..5b0f9ff8a07 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -1,25 +1,6 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PluginRuntime } from "../../runtime-api.js"; import { setMatrixRuntime } from "../runtime.js"; -import { createMatrixBotSdkMock } from "../test-mocks.js"; - -vi.mock("music-metadata", () => ({ - // `resolveMediaDurationMs` lazily imports `music-metadata`; in tests we don't - // need real duration parsing and the real module is expensive to load. - parseBuffer: vi.fn().mockResolvedValue({ format: {} }), -})); - -vi.mock("@vector-im/matrix-bot-sdk", () => - createMatrixBotSdkMock({ - matrixClient: vi.fn(), - simpleFsStorageProvider: vi.fn(), - rustSdkCryptoStorageProvider: vi.fn(), - }), -); - -vi.mock("./send-queue.js", () => ({ - enqueueSend: async (_roomId: string, fn: () => Promise) => await fn(), -})); const loadWebMediaMock = vi.fn().mockResolvedValue({ buffer: Buffer.from("media"), @@ -27,28 +8,28 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({ contentType: "image/png", kind: "image", }); -const runtimeLoadConfigMock = vi.fn(() => ({})); -const mediaKindFromMimeMock = vi.fn(() => "image"); -const isVoiceCompatibleAudioMock = vi.fn(() => false); +const loadConfigMock = vi.fn(() => ({})); const getImageMetadataMock = vi.fn().mockResolvedValue(null); const resizeToJpegMock = vi.fn(); +const resolveTextChunkLimitMock = vi.fn< + (cfg: unknown, channel: unknown, accountId?: unknown) => number +>(() => 4000); const runtimeStub = { config: { - loadConfig: runtimeLoadConfigMock, + loadConfig: () => loadConfigMock(), }, media: { - loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"], - mediaKindFromMime: - mediaKindFromMimeMock as unknown as PluginRuntime["media"]["mediaKindFromMime"], - isVoiceCompatibleAudio: - isVoiceCompatibleAudioMock as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"], - getImageMetadata: getImageMetadataMock as unknown as PluginRuntime["media"]["getImageMetadata"], - resizeToJpeg: resizeToJpegMock as unknown as PluginRuntime["media"]["resizeToJpeg"], + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), + mediaKindFromMime: () => "image", + isVoiceCompatibleAudio: () => false, + getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), + resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), }, channel: { text: { - resolveTextChunkLimit: () => 4000, + resolveTextChunkLimit: (cfg: unknown, channel: unknown, accountId?: unknown) => + resolveTextChunkLimitMock(cfg, channel, accountId), resolveChunkMode: () => "length", chunkMarkdownText: (text: string) => (text ? [text] : []), chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []), @@ -59,32 +40,47 @@ const runtimeStub = { } as unknown as PluginRuntime; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; -let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes; +let sendTypingMatrix: typeof import("./send.js").sendTypingMatrix; +let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll; const makeClient = () => { const sendMessage = vi.fn().mockResolvedValue("evt1"); + const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote"); + const getEvent = vi.fn(); const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); const client = { sendMessage, + sendEvent, + getEvent, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; - return { client, sendMessage, uploadContent }; + prepareForOneOff: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(() => undefined), + stopAndPersist: vi.fn(async () => undefined), + } as unknown as import("./sdk.js").MatrixClient; + return { client, sendMessage, sendEvent, getEvent, uploadContent }; }; -beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ sendMessageMatrix } = await import("./send.js")); - ({ resolveMediaMaxBytes } = await import("./send/client.js")); -}); - describe("sendMessageMatrix media", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + ({ sendTypingMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); + }); + beforeEach(() => { - vi.clearAllMocks(); - runtimeLoadConfigMock.mockReset(); - runtimeLoadConfigMock.mockReturnValue({}); - mediaKindFromMimeMock.mockReturnValue("image"); - isVoiceCompatibleAudioMock.mockReturnValue(false); + loadWebMediaMock.mockReset().mockResolvedValue({ + buffer: Buffer.from("media"), + fileName: "photo.png", + contentType: "image/png", + kind: "image", + }); + loadConfigMock.mockReset().mockReturnValue({}); + getImageMetadataMock.mockReset().mockResolvedValue(null); + resizeToJpegMock.mockReset(); + resolveTextChunkLimitMock.mockReset().mockReturnValue(4000); setMatrixRuntime(runtimeStub); }); @@ -148,72 +144,132 @@ describe("sendMessageMatrix media", () => { expect(content.file?.url).toBe("mxc://example/file"); }); - it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => { - const { client, sendMessage } = makeClient(); - mediaKindFromMimeMock.mockReturnValue("audio"); - isVoiceCompatibleAudioMock.mockReturnValue(true); - loadWebMediaMock.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - fileName: "clip.mp3", - contentType: "audio/mpeg", - kind: "audio", - }); - - await sendMessageMatrix("room:!room:example", "voice caption", { - client, - mediaUrl: "file:///tmp/clip.mp3", - audioAsVoice: true, - }); - - expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({ - contentType: "audio/mpeg", - fileName: "clip.mp3", - }); - expect(sendMessage).toHaveBeenCalledTimes(2); - const mediaContent = sendMessage.mock.calls[0]?.[1] as { - msgtype?: string; - body?: string; - "org.matrix.msc3245.voice"?: Record; + it("does not upload plaintext thumbnails for encrypted image sends", async () => { + const { client, uploadContent } = makeClient(); + (client as { crypto?: object }).crypto = { + isRoomEncrypted: vi.fn().mockResolvedValue(true), + encryptMedia: vi.fn().mockResolvedValue({ + buffer: Buffer.from("encrypted"), + file: { + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + }), }; - expect(mediaContent.msgtype).toBe("m.audio"); - expect(mediaContent.body).toBe("Voice message"); - expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({}); + getImageMetadataMock + .mockResolvedValueOnce({ width: 1600, height: 1200 }) + .mockResolvedValueOnce({ width: 800, height: 600 }); + resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb")); + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + expect(uploadContent).toHaveBeenCalledTimes(1); }); - it("keeps regular audio payload when audioAsVoice media is incompatible", async () => { - const { client, sendMessage } = makeClient(); - mediaKindFromMimeMock.mockReturnValue("audio"); - isVoiceCompatibleAudioMock.mockReturnValue(false); - loadWebMediaMock.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - fileName: "clip.wav", - contentType: "audio/wav", - kind: "audio", - }); + it("uploads thumbnail metadata for unencrypted large images", async () => { + const { client, sendMessage, uploadContent } = makeClient(); + getImageMetadataMock + .mockResolvedValueOnce({ width: 1600, height: 1200 }) + .mockResolvedValueOnce({ width: 800, height: 600 }); + resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb")); - await sendMessageMatrix("room:!room:example", "voice caption", { + await sendMessageMatrix("room:!room:example", "caption", { client, - mediaUrl: "file:///tmp/clip.wav", - audioAsVoice: true, + mediaUrl: "file:///tmp/photo.png", }); - expect(sendMessage).toHaveBeenCalledTimes(1); - const mediaContent = sendMessage.mock.calls[0]?.[1] as { - msgtype?: string; - body?: string; - "org.matrix.msc3245.voice"?: Record; + expect(uploadContent).toHaveBeenCalledTimes(2); + const content = sendMessage.mock.calls[0]?.[1] as { + info?: { + thumbnail_url?: string; + thumbnail_info?: { + w?: number; + h?: number; + mimetype?: string; + size?: number; + }; + }; }; - expect(mediaContent.msgtype).toBe("m.audio"); - expect(mediaContent.body).toBe("voice caption"); - expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined(); + expect(content.info?.thumbnail_url).toBe("mxc://example/file"); + expect(content.info?.thumbnail_info).toMatchObject({ + w: 800, + h: 600, + mimetype: "image/jpeg", + size: Buffer.from("thumb").byteLength, + }); + }); + + it("uses explicit cfg for media sends instead of runtime loadConfig fallbacks", async () => { + const { client } = makeClient(); + const explicitCfg = { + channels: { + matrix: { + accounts: { + ops: { + mediaMaxMb: 1, + }, + }, + }, + }, + }; + + loadConfigMock.mockImplementation(() => { + throw new Error("sendMessageMatrix should not reload runtime config when cfg is provided"); + }); + + await sendMessageMatrix("room:!room:example", "caption", { + client, + cfg: explicitCfg, + accountId: "ops", + mediaUrl: "file:///tmp/photo.png", + }); + + expect(loadConfigMock).not.toHaveBeenCalled(); + expect(loadWebMediaMock).toHaveBeenCalledWith("file:///tmp/photo.png", { + maxBytes: 1024 * 1024, + localRoots: undefined, + }); + expect(resolveTextChunkLimitMock).toHaveBeenCalledWith(explicitCfg, "matrix", "ops"); + }); + + it("passes caller mediaLocalRoots to media loading", async () => { + const { client } = makeClient(); + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + }); + + expect(loadWebMediaMock).toHaveBeenCalledWith("file:///tmp/photo.png", { + maxBytes: undefined, + localRoots: ["/tmp/openclaw-matrix-test"], + }); }); }); describe("sendMessageMatrix threads", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + ({ sendTypingMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); + }); + beforeEach(() => { vi.clearAllMocks(); - runtimeLoadConfigMock.mockReset(); - runtimeLoadConfigMock.mockReturnValue({}); + loadConfigMock.mockReset().mockReturnValue({}); setMatrixRuntime(runtimeStub); }); @@ -239,81 +295,187 @@ describe("sendMessageMatrix threads", () => { "m.in_reply_to": { event_id: "$thread" }, }); }); + + it("resolves text chunk limit using the active Matrix account", async () => { + const { client } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello", { + client, + accountId: "ops", + }); + + expect(resolveTextChunkLimitMock).toHaveBeenCalledWith(expect.anything(), "matrix", "ops"); + }); }); -describe("sendMessageMatrix cfg threading", () => { +describe("voteMatrixPoll", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ voteMatrixPoll } = await import("./actions/polls.js")); + }); + beforeEach(() => { vi.clearAllMocks(); - runtimeLoadConfigMock.mockReset(); - runtimeLoadConfigMock.mockReturnValue({ - channels: { - matrix: { - mediaMaxMb: 7, - }, - }, - }); + loadConfigMock.mockReset().mockReturnValue({}); setMatrixRuntime(runtimeStub); }); - it("does not call runtime loadConfig when cfg is provided", async () => { - const { client } = makeClient(); - const providedCfg = { - channels: { - matrix: { - mediaMaxMb: 4, + it("maps 1-based option indexes to Matrix poll answer ids", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], }, }, - }; + }); - await sendMessageMatrix("room:!room:example", "hello cfg", { + const result = await voteMatrixPoll("room:!room:example", "$poll", { client, - cfg: providedCfg as any, + optionIndex: 2, }); - expect(runtimeLoadConfigMock).not.toHaveBeenCalled(); + expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", { + "m.poll.response": { answers: ["a2"] }, + "org.matrix.msc3381.poll.response": { answers: ["a2"] }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + expect(result).toMatchObject({ + eventId: "evt-poll-vote", + roomId: "!room:example", + pollId: "$poll", + answerIds: ["a2"], + labels: ["Sushi"], + }); }); - it("falls back to runtime loadConfig when cfg is omitted", async () => { - const { client } = makeClient(); - - await sendMessageMatrix("room:!room:example", "hello runtime", { client }); - - expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1); - }); -}); - -describe("resolveMediaMaxBytes cfg threading", () => { - beforeEach(() => { - runtimeLoadConfigMock.mockReset(); - runtimeLoadConfigMock.mockReturnValue({ - channels: { - matrix: { - mediaMaxMb: 9, + it("rejects out-of-range option indexes", async () => { + const { client, getEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [{ id: "a1", "m.text": "Pizza" }], }, }, }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 2, + }), + ).rejects.toThrow("out of range"); + }); + + it("rejects votes that exceed the poll selection cap", async () => { + const { client, getEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [ + { id: "a1", "m.text": "Pizza" }, + { id: "a2", "m.text": "Sushi" }, + ], + }, + }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndexes: [1, 2], + }), + ).rejects.toThrow("at most 1 selection"); + }); + + it("rejects non-poll events before sending a response", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.room.message", + content: { body: "hello" }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 1, + }), + ).rejects.toThrow("is not a Matrix poll start event"); + expect(sendEvent).not.toHaveBeenCalled(); + }); + + it("accepts decrypted poll start events returned from encrypted rooms", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.poll.start", + content: { + "m.poll.start": { + question: { "m.text": "Lunch?" }, + max_selections: 1, + answers: [{ id: "a1", "m.text": "Pizza" }], + }, + }, + }); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 1, + }), + ).resolves.toMatchObject({ + pollId: "$poll", + answerIds: ["a1"], + }); + expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", { + "m.poll.response": { answers: ["a1"] }, + "org.matrix.msc3381.poll.response": { answers: ["a1"] }, + "m.relates_to": { + rel_type: "m.reference", + event_id: "$poll", + }, + }); + }); +}); + +describe("sendTypingMatrix", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendTypingMatrix } = await import("./send.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + loadConfigMock.mockReset().mockReturnValue({}); setMatrixRuntime(runtimeStub); }); - it("uses provided cfg and skips runtime loadConfig", () => { - const providedCfg = { - channels: { - matrix: { - mediaMaxMb: 3, - }, - }, - }; + it("normalizes room-prefixed targets before sending typing state", async () => { + const setTyping = vi.fn().mockResolvedValue(undefined); + const client = { + setTyping, + prepareForOneOff: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(() => undefined), + stopAndPersist: vi.fn(async () => undefined), + } as unknown as import("./sdk.js").MatrixClient; - const maxBytes = resolveMediaMaxBytes(undefined, providedCfg as any); + await sendTypingMatrix("room:!room:example", true, undefined, client); - expect(maxBytes).toBe(3 * 1024 * 1024); - expect(runtimeLoadConfigMock).not.toHaveBeenCalled(); - }); - - it("falls back to runtime loadConfig when cfg is omitted", () => { - const maxBytes = resolveMediaMaxBytes(); - - expect(maxBytes).toBe(9 * 1024 * 1024); - expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1); + expect(setTyping).toHaveBeenCalledWith("!room:example", true, 30_000); }); }); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 8820b2fbbc1..f0fcf75c6f7 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,9 +1,10 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { PollInput } from "../../runtime-api.js"; +import type { PollInput } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../runtime.js"; +import type { CoreConfig } from "../types.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; -import { enqueueSend } from "./send-queue.js"; -import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; +import { buildMatrixReactionContent } from "./reaction-common.js"; +import type { MatrixClient } from "./sdk.js"; +import { resolveMediaMaxBytes, withResolvedMatrixClient } from "./send/client.js"; import { buildReplyRelation, buildTextContent, @@ -21,11 +22,9 @@ import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; import { EventType, MsgType, - RelationType, type MatrixOutboundContent, type MatrixSendOpts, type MatrixSendResult, - type ReactionEventContent, } from "./send/types.js"; const MATRIX_TEXT_LIMIT = 4000; @@ -34,25 +33,53 @@ const getCore = () => getMatrixRuntime(); export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; export { resolveMatrixRoomId } from "./send/targets.js"; +type MatrixClientResolveOpts = { + client?: MatrixClient; + cfg?: CoreConfig; + timeoutMs?: number; + accountId?: string | null; +}; + +function isMatrixClient(value: MatrixClient | MatrixClientResolveOpts): value is MatrixClient { + return typeof (value as { sendEvent?: unknown }).sendEvent === "function"; +} + +function normalizeMatrixClientResolveOpts( + opts?: MatrixClient | MatrixClientResolveOpts, +): MatrixClientResolveOpts { + if (!opts) { + return {}; + } + if (isMatrixClient(opts)) { + return { client: opts }; + } + return { + client: opts.client, + cfg: opts.cfg, + timeoutMs: opts.timeoutMs, + accountId: opts.accountId, + }; +} + export async function sendMessageMatrix( to: string, - message: string, + message: string | undefined, opts: MatrixSendOpts = {}, ): Promise { const trimmedMessage = message?.trim() ?? ""; if (!trimmedMessage && !opts.mediaUrl) { throw new Error("Matrix send requires text or media"); } - const { client, stopOnDone } = await resolveMatrixClient({ - client: opts.client, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - cfg: opts.cfg, - }); - const cfg = opts.cfg ?? getCore().config.loadConfig(); - try { - const roomId = await resolveMatrixRoomId(client, to); - return await enqueueSend(roomId, async () => { + return await withResolvedMatrixClient( + { + client: opts.client, + cfg: opts.cfg, + timeoutMs: opts.timeoutMs, + accountId: opts.accountId, + }, + async (client) => { + const roomId = await resolveMatrixRoomId(client, to); + const cfg = opts.cfg ?? getCore().config.loadConfig(); const tableMode = getCore().channel.text.resolveMarkdownTableMode({ cfg, channel: "matrix", @@ -62,7 +89,7 @@ export async function sendMessageMatrix( trimmedMessage, tableMode, ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix", opts.accountId); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); const chunks = getCore().channel.text.chunkMarkdownTextWithMode( @@ -75,7 +102,6 @@ export async function sendMessageMatrix( ? buildThreadRelation(threadId, opts.replyToId) : buildReplyRelation(opts.replyToId); const sendContent = async (content: MatrixOutboundContent) => { - // @vector-im/matrix-bot-sdk uses sendMessage differently const eventId = await client.sendMessage(roomId, content); return eventId; }; @@ -83,7 +109,10 @@ export async function sendMessageMatrix( let lastMessageId = ""; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, { + maxBytes, + localRoots: opts.mediaLocalRoots, + }); const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { contentType: media.contentType, filename: media.fileName, @@ -103,7 +132,11 @@ export async function sendMessageMatrix( const msgtype = useVoice ? MsgType.Audio : baseMsgType; const isImage = msgtype === MsgType.Image; const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) + ? await prepareImageInfo({ + buffer: media.buffer, + client, + encrypted: Boolean(uploaded.file), + }) : undefined; const [firstChunk, ...rest] = chunks; const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); @@ -149,12 +182,8 @@ export async function sendMessageMatrix( messageId: lastMessageId || "unknown", roomId, }; - }); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }, + ); } export async function sendPollMatrix( @@ -168,32 +197,28 @@ export async function sendPollMatrix( if (!poll.options?.length) { throw new Error("Matrix poll requires options"); } - const { client, stopOnDone } = await resolveMatrixClient({ - client: opts.client, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - cfg: opts.cfg, - }); + return await withResolvedMatrixClient( + { + client: opts.client, + cfg: opts.cfg, + timeoutMs: opts.timeoutMs, + accountId: opts.accountId, + }, + async (client) => { + const roomId = await resolveMatrixRoomId(client, to); + const pollContent = buildPollStartContent(poll); + const threadId = normalizeThreadId(opts.threadId); + const pollPayload = threadId + ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } + : pollContent; + const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); - try { - const roomId = await resolveMatrixRoomId(client, to); - const pollContent = buildPollStartContent(poll); - const threadId = normalizeThreadId(opts.threadId); - const pollPayload = threadId - ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } - : pollContent; - // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly - const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); - - return { - eventId: eventId ?? "unknown", - roomId, - }; - } finally { - if (stopOnDone) { - client.stop(); - } - } + return { + eventId: eventId ?? "unknown", + roomId, + }; + }, + ); } export async function sendTypingMatrix( @@ -202,18 +227,17 @@ export async function sendTypingMatrix( timeoutMs?: number, client?: MatrixClient, ): Promise { - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, - timeoutMs, - }); - try { - const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; - await resolved.setTyping(roomId, typing, resolvedTimeoutMs); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } + await withResolvedMatrixClient( + { + client, + timeoutMs, + }, + async (resolved) => { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; + await resolved.setTyping(resolvedRoom, typing, resolvedTimeoutMs); + }, + ); } export async function sendReadReceiptMatrix( @@ -224,44 +248,30 @@ export async function sendReadReceiptMatrix( if (!eventId?.trim()) { return; } - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, - }); - try { + await withResolvedMatrixClient({ client }, async (resolved) => { const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); await resolved.sendReadReceipt(resolvedRoom, eventId.trim()); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } + }); } export async function reactMatrixMessage( roomId: string, messageId: string, emoji: string, - client?: MatrixClient, + opts?: MatrixClient | MatrixClientResolveOpts, ): Promise { - if (!emoji.trim()) { - throw new Error("Matrix reaction requires an emoji"); - } - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, - }); - try { - const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); - const reaction: ReactionEventContent = { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: messageId, - key: emoji, - }, - }; - await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } + const clientOpts = normalizeMatrixClientResolveOpts(opts); + await withResolvedMatrixClient( + { + client: clientOpts.client, + cfg: clientOpts.cfg, + timeoutMs: clientOpts.timeoutMs, + accountId: clientOpts.accountId ?? undefined, + }, + async (resolved) => { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + const reaction = buildMatrixReactionContent(messageId, emoji); + await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); + }, + ); } diff --git a/extensions/matrix/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts new file mode 100644 index 00000000000..f3426052ffe --- /dev/null +++ b/extensions/matrix/src/matrix/send/client.test.ts @@ -0,0 +1,135 @@ +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("../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), +})); + +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => getMatrixRuntimeMock(), +})); + +const { withResolvedMatrixClient } = await import("./client.js"); + +describe("withResolvedMatrixClient", () => { + beforeEach(() => { + primeMatrixClientResolverMocks({ + resolved: {}, + }); + }); + + 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 withResolvedMatrixClient({ 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("reuses active monitor client when available", async () => { + const activeClient = createMockMatrixClient(); + getActiveMatrixClientMock.mockReturnValue(activeClient); + + const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => { + expect(client).toBe(activeClient); + return "ok"; + }); + + expect(result).toBe("ok"); + expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled(); + expect(activeClient.stop).not.toHaveBeenCalled(); + }); + + it("uses the effective account id when auth resolution is implicit", async () => { + resolveMatrixAuthContextMock.mockReturnValue({ + cfg: {}, + env: process.env, + accountId: "ops", + resolved: {}, + }); + await withResolvedMatrixClient({}, async () => {}); + + expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops"); + expect(acquireSharedMatrixClientMock).toHaveBeenCalledWith({ + cfg: {}, + timeoutMs: undefined, + accountId: "ops", + startClient: false, + }); + }); + + it("uses explicit cfg instead of loading runtime config", async () => { + const explicitCfg = { + channels: { + matrix: { + defaultAccount: "ops", + }, + }, + }; + + await withResolvedMatrixClient({ 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 matrix clients when wrapped sends fail", async () => { + const sharedClient = createMockMatrixClient(); + acquireSharedMatrixClientMock.mockResolvedValue(sharedClient); + + await expect( + withResolvedMatrixClient({ accountId: "default" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop"); + }); +}); diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index e56cf493758..f68d8e8c7f9 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,99 +1,38 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { getMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; -import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js"; -import { createPreparedMatrixClient } from "../client-bootstrap.js"; -import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js"; +import { resolveMatrixAccountConfig } from "../accounts.js"; +import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js"; +import type { MatrixClient } from "../sdk.js"; const getCore = () => getMatrixRuntime(); -export function ensureNodeRuntime() { - if (isBunRuntime()) { - throw new Error("Matrix support requires Node (bun runtime not supported)"); - } -} - -/** Look up account config with case-insensitive key fallback. */ -function findAccountConfig( - accounts: Record | undefined, - accountId: string, -): Record | undefined { - if (!accounts) return undefined; - const normalized = normalizeAccountId(accountId); - // Direct lookup first - if (accounts[normalized]) return accounts[normalized] as Record; - // Case-insensitive fallback - for (const key of Object.keys(accounts)) { - if (normalizeAccountId(key) === normalized) { - return accounts[key] as Record; - } - } - return undefined; -} - -export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined { +export function resolveMediaMaxBytes( + accountId?: string | null, + cfg?: CoreConfig, +): number | undefined { const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig); - // Check account-specific config first (case-insensitive key matching) - const accountConfig = findAccountConfig( - resolvedCfg.channels?.matrix?.accounts as Record | undefined, - accountId ?? "", - ); - if (typeof accountConfig?.mediaMaxMb === "number") { - return (accountConfig.mediaMaxMb as number) * 1024 * 1024; - } - // Fall back to top-level config - if (typeof resolvedCfg.channels?.matrix?.mediaMaxMb === "number") { - return resolvedCfg.channels.matrix.mediaMaxMb * 1024 * 1024; + const matrixCfg = resolveMatrixAccountConfig({ cfg: resolvedCfg, accountId }); + const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined; + if (typeof mediaMaxMb === "number") { + return mediaMaxMb * 1024 * 1024; } return undefined; } -export async function resolveMatrixClient(opts: { - client?: MatrixClient; - timeoutMs?: number; - accountId?: string; - cfg?: CoreConfig; -}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { - ensureNodeRuntime(); - if (opts.client) { - return { client: opts.client, stopOnDone: false }; - } - const accountId = - typeof opts.accountId === "string" && opts.accountId.trim().length > 0 - ? normalizeAccountId(opts.accountId) - : undefined; - // Try to get the client for the specific account - const active = getActiveMatrixClient(accountId); - if (active) { - return { client: active, stopOnDone: false }; - } - // When no account is specified, try the default account first; only fall back to - // any active client as a last resort (prevents sending from an arbitrary account). - if (!accountId) { - const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID); - if (defaultClient) { - return { client: defaultClient, stopOnDone: false }; - } - const anyActive = getAnyActiveMatrixClient(); - if (anyActive) { - return { client: anyActive, stopOnDone: false }; - } - } - const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); - if (shouldShareClient) { - const client = await resolveSharedMatrixClient({ - timeoutMs: opts.timeoutMs, - accountId, - cfg: opts.cfg, - }); - return { client, stopOnDone: false }; - } - const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg }); - const client = await createPreparedMatrixClient({ - auth, - timeoutMs: opts.timeoutMs, - accountId, - }); - return { client, stopOnDone: true }; +export async function withResolvedMatrixClient( + opts: { + client?: MatrixClient; + cfg?: CoreConfig; + timeoutMs?: number; + accountId?: string | null; + }, + run: (client: MatrixClient) => Promise, +): Promise { + return await withResolvedRuntimeMatrixClient( + { + ...opts, + readiness: "prepared", + }, + run, + ); } diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts index 2d15e74cb4d..bf0ed1989be 100644 --- a/extensions/matrix/src/matrix/send/formatting.ts +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -85,7 +85,7 @@ export function resolveMatrixVoiceDecision(opts: { function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { // Matrix currently shares the core voice compatibility policy. - // Keep this wrapper as the boundary if Matrix policy diverges later. + // Keep this wrapper as the seam if Matrix policy diverges later. return getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName, diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts index eecdce3d565..03d5d98d324 100644 --- a/extensions/matrix/src/matrix/send/media.ts +++ b/extensions/matrix/src/matrix/send/media.ts @@ -1,3 +1,5 @@ +import { parseBuffer, type IFileInfo } from "music-metadata"; +import { getMatrixRuntime } from "../../runtime.js"; import type { DimensionalFileInfo, EncryptedFile, @@ -5,8 +7,7 @@ import type { MatrixClient, TimedFileInfo, VideoFileInfo, -} from "@vector-im/matrix-bot-sdk"; -import { getMatrixRuntime } from "../../runtime.js"; +} from "../sdk.js"; import { applyMatrixFormatting } from "./formatting.js"; import { type MatrixMediaContent, @@ -17,7 +18,6 @@ import { } from "./types.js"; const getCore = () => getMatrixRuntime(); -type IFileInfo = import("music-metadata").IFileInfo; export function buildMatrixMediaInfo(params: { size: number; @@ -113,6 +113,7 @@ const THUMBNAIL_QUALITY = 80; export async function prepareImageInfo(params: { buffer: Buffer; client: MatrixClient; + encrypted?: boolean; }): Promise { const meta = await getCore() .media.getImageMetadata(params.buffer) @@ -121,6 +122,10 @@ export async function prepareImageInfo(params: { return undefined; } const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; + if (params.encrypted) { + // For E2EE media, avoid uploading plaintext thumbnails. + return imageInfo; + } const maxDim = Math.max(meta.width, meta.height); if (maxDim > THUMBNAIL_MAX_SIDE) { try { @@ -164,7 +169,6 @@ export async function resolveMediaDurationMs(params: { return undefined; } try { - const { parseBuffer } = await import("music-metadata"); const fileInfo: IFileInfo | string | undefined = params.contentType || params.fileName ? { diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts index 0bc90327cc8..16ccc9b05f0 100644 --- a/extensions/matrix/src/matrix/send/targets.test.ts +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -1,13 +1,11 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; import { EventType } from "./types.js"; -let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; -let normalizeThreadId: typeof import("./targets.js").normalizeThreadId; +const { resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"); -beforeEach(async () => { - vi.resetModules(); - ({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js")); +beforeEach(() => { + vi.clearAllMocks(); }); describe("resolveMatrixRoomId", () => { @@ -17,8 +15,9 @@ describe("resolveMatrixRoomId", () => { getAccountData: vi.fn().mockResolvedValue({ [userId]: ["!room:example.org"], }), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), getJoinedRooms: vi.fn(), - getJoinedRoomMembers: vi.fn(), + getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]), setAccountData: vi.fn(), } as unknown as MatrixClient; @@ -37,6 +36,7 @@ describe("resolveMatrixRoomId", () => { const setAccountData = vi.fn().mockResolvedValue(undefined); const client = { getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), getJoinedRooms: vi.fn().mockResolvedValue([roomId]), getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]), setAccountData, @@ -61,6 +61,7 @@ describe("resolveMatrixRoomId", () => { .mockResolvedValueOnce(["@bot:example.org", userId]); const client = { getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]), getJoinedRoomMembers, setAccountData, @@ -72,11 +73,12 @@ describe("resolveMatrixRoomId", () => { expect(setAccountData).toHaveBeenCalled(); }); - it("allows larger rooms when no 1:1 match exists", async () => { + it("does not fall back to larger shared rooms for direct-user sends", async () => { const userId = "@group:example.org"; const roomId = "!group:example.org"; const client = { getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), getJoinedRooms: vi.fn().mockResolvedValue([roomId]), getJoinedRoomMembers: vi .fn() @@ -84,9 +86,117 @@ describe("resolveMatrixRoomId", () => { setAccountData: vi.fn().mockResolvedValue(undefined), } as unknown as MatrixClient; - const resolved = await resolveMatrixRoomId(client, userId); + await expect(resolveMatrixRoomId(client, userId)).rejects.toThrow( + `No direct room found for ${userId} (m.direct missing)`, + ); + // oxlint-disable-next-line typescript/unbound-method + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("accepts nested Matrix user target prefixes", async () => { + const userId = "@prefixed:example.org"; + const roomId = "!prefixed-room:example.org"; + const client = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: [roomId], + }), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, `matrix:user:${userId}`); expect(resolved).toBe(roomId); + // oxlint-disable-next-line typescript/unbound-method + expect(client.resolveRoom).not.toHaveBeenCalled(); + }); + + it("scopes direct-room cache per Matrix client", async () => { + const userId = "@shared:example.org"; + const clientA = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: ["!room-a:example.org"], + }), + getUserId: vi.fn().mockResolvedValue("@bot-a:example.org"), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot-a:example.org", userId]), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + const clientB = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: ["!room-b:example.org"], + }), + getUserId: vi.fn().mockResolvedValue("@bot-b:example.org"), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot-b:example.org", userId]), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + + await expect(resolveMatrixRoomId(clientA, userId)).resolves.toBe("!room-a:example.org"); + await expect(resolveMatrixRoomId(clientB, userId)).resolves.toBe("!room-b:example.org"); + + // oxlint-disable-next-line typescript/unbound-method + expect(clientA.getAccountData).toHaveBeenCalledTimes(1); + // oxlint-disable-next-line typescript/unbound-method + expect(clientB.getAccountData).toHaveBeenCalledTimes(1); + }); + + it("ignores m.direct entries that point at shared rooms", async () => { + const userId = "@shared:example.org"; + const client = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: ["!shared-room:example.org", "!dm-room:example.org"], + }), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi + .fn() + .mockResolvedValueOnce(["@bot:example.org", userId, "@extra:example.org"]) + .mockResolvedValueOnce(["@bot:example.org", userId]), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + + await expect(resolveMatrixRoomId(client, userId)).resolves.toBe("!dm-room:example.org"); + }); + + it("revalidates cached direct rooms before reuse when membership changes", async () => { + const userId = "@shared:example.org"; + const directRooms = ["!dm-room-1:example.org"]; + const membersByRoom = new Map([ + ["!dm-room-1:example.org", ["@bot:example.org", userId]], + ["!dm-room-2:example.org", ["@bot:example.org", userId]], + ]); + const client = { + getAccountData: vi.fn().mockImplementation(async () => ({ + [userId]: [...directRooms], + })), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getJoinedRooms: vi + .fn() + .mockResolvedValue(["!dm-room-1:example.org", "!dm-room-2:example.org"]), + getJoinedRoomMembers: vi + .fn() + .mockImplementation(async (roomId: string) => membersByRoom.get(roomId) ?? []), + setAccountData: vi.fn(), + resolveRoom: vi.fn(), + } as unknown as MatrixClient; + + await expect(resolveMatrixRoomId(client, userId)).resolves.toBe("!dm-room-1:example.org"); + + directRooms.splice(0, directRooms.length, "!dm-room-1:example.org", "!dm-room-2:example.org"); + membersByRoom.set("!dm-room-1:example.org", [ + "@bot:example.org", + userId, + "@mallory:example.org", + ]); + + await expect(resolveMatrixRoomId(client, userId)).resolves.toBe("!dm-room-2:example.org"); }); }); diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index d4d4e2b6e0d..de35b6aaccb 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -1,5 +1,7 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { EventType, type MatrixDirectAccountData } from "./types.js"; +import { inspectMatrixDirectRooms, persistMatrixDirectRoomMapping } from "../direct-management.js"; +import { isStrictDirectRoom } from "../direct-room.js"; +import type { MatrixClient } from "../sdk.js"; +import { isMatrixQualifiedUserId, normalizeMatrixResolvableTarget } from "../target-ids.js"; function normalizeTarget(raw: string): string { const trimmed = raw.trim(); @@ -19,8 +21,20 @@ export function normalizeThreadId(raw?: string | number | null): string | null { // Size-capped to prevent unbounded growth (#4948) const MAX_DIRECT_ROOM_CACHE_SIZE = 1024; -const directRoomCache = new Map(); -function setDirectRoomCached(key: string, value: string): void { +const directRoomCacheByClient = new WeakMap>(); + +function resolveDirectRoomCache(client: MatrixClient): Map { + const existing = directRoomCacheByClient.get(client); + if (existing) { + return existing; + } + const created = new Map(); + directRoomCacheByClient.set(client, created); + return created; +} + +function setDirectRoomCached(client: MatrixClient, key: string, value: string): void { + const directRoomCache = resolveDirectRoomCache(client); directRoomCache.set(key, value); if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) { const oldest = directRoomCache.keys().next().value; @@ -30,113 +44,53 @@ function setDirectRoomCached(key: string, value: string): void { } } -async function persistDirectRoom( - client: MatrixClient, - userId: string, - roomId: string, -): Promise { - let directContent: MatrixDirectAccountData | null = null; - try { - directContent = await client.getAccountData(EventType.Direct); - } catch { - // Ignore fetch errors and fall back to an empty map. - } - const existing = directContent && !Array.isArray(directContent) ? directContent : {}; - const current = Array.isArray(existing[userId]) ? existing[userId] : []; - if (current[0] === roomId) { - return; - } - const next = [roomId, ...current.filter((id) => id !== roomId)]; - try { - await client.setAccountData(EventType.Direct, { - ...existing, - [userId]: next, - }); - } catch { - // Ignore persistence errors. - } -} - async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { const trimmed = userId.trim(); - if (!trimmed.startsWith("@")) { + if (!isMatrixQualifiedUserId(trimmed)) { throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); } + const selfUserId = (await client.getUserId().catch(() => null))?.trim() || null; + const directRoomCache = resolveDirectRoomCache(client); const cached = directRoomCache.get(trimmed); - if (cached) { + if ( + cached && + (await isStrictDirectRoom({ client, roomId: cached, remoteUserId: trimmed, selfUserId })) + ) { return cached; } - - // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot). - try { - const directContent = (await client.getAccountData(EventType.Direct)) as Record< - string, - string[] | undefined - >; - const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; - if (list && list.length > 0) { - setDirectRoomCached(trimmed, list[0]); - return list[0]; - } - } catch { - // Ignore and fall back. + if (cached) { + directRoomCache.delete(trimmed); } - // 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. - // Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. - let fallbackRoom: string | null = null; - try { - const rooms = await client.getJoinedRooms(); - for (const roomId of rooms) { - let members: string[]; - try { - members = await client.getJoinedRoomMembers(roomId); - } catch { - continue; - } - if (!members.includes(trimmed)) { - continue; - } - // Prefer classic 1:1 rooms, but allow larger rooms if requested. - if (members.length === 2) { - setDirectRoomCached(trimmed, roomId); - await persistDirectRoom(client, trimmed, roomId); - return roomId; - } - if (!fallbackRoom) { - fallbackRoom = roomId; - } + const inspection = await inspectMatrixDirectRooms({ + client, + remoteUserId: trimmed, + }); + if (inspection.activeRoomId) { + setDirectRoomCached(client, trimmed, inspection.activeRoomId); + if (inspection.mappedRoomIds[0] !== inspection.activeRoomId) { + await persistMatrixDirectRoomMapping({ + client, + remoteUserId: trimmed, + roomId: inspection.activeRoomId, + }).catch(() => { + // Ignore persistence errors when send resolution has already found a usable room. + }); } - } catch { - // Ignore and fall back. - } - - if (fallbackRoom) { - setDirectRoomCached(trimmed, fallbackRoom); - await persistDirectRoom(client, trimmed, fallbackRoom); - return fallbackRoom; + return inspection.activeRoomId; } throw new Error(`No direct room found for ${trimmed} (m.direct missing)`); } export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise { - const target = normalizeTarget(raw); + const target = normalizeMatrixResolvableTarget(normalizeTarget(raw)); const lowered = target.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return await resolveMatrixRoomId(client, target.slice("matrix:".length)); - } - if (lowered.startsWith("room:")) { - return await resolveMatrixRoomId(client, target.slice("room:".length)); - } - if (lowered.startsWith("channel:")) { - return await resolveMatrixRoomId(client, target.slice("channel:".length)); - } if (lowered.startsWith("user:")) { return await resolveDirectRoomId(client, target.slice("user:".length)); } - if (target.startsWith("@")) { + if (isMatrixQualifiedUserId(target)) { return await resolveDirectRoomId(client, target); } if (target.startsWith("#")) { diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index e3aec1dcae7..2d2d8bf3715 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -1,3 +1,9 @@ +import type { CoreConfig } from "../../types.js"; +import { + MATRIX_ANNOTATION_RELATION_TYPE, + MATRIX_REACTION_EVENT_TYPE, + type MatrixReactionEventContent, +} from "../reaction-common.js"; import type { DimensionalFileInfo, EncryptedFile, @@ -6,7 +12,7 @@ import type { TextualMessageEventContent, TimedFileInfo, VideoFileInfo, -} from "@vector-im/matrix-bot-sdk"; +} from "../sdk.js"; // Message types export const MsgType = { @@ -20,7 +26,7 @@ export const MsgType = { // Relation types export const RelationType = { - Annotation: "m.annotation", + Annotation: MATRIX_ANNOTATION_RELATION_TYPE, Replace: "m.replace", Thread: "m.thread", } as const; @@ -28,7 +34,7 @@ export const RelationType = { // Event types export const EventType = { Direct: "m.direct", - Reaction: "m.reaction", + Reaction: MATRIX_REACTION_EVENT_TYPE, RoomMessage: "m.room.message", } as const; @@ -71,13 +77,7 @@ export type MatrixMediaContent = MessageEventContent & export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; -export type ReactionEventContent = { - "m.relates_to": { - rel_type: typeof RelationType.Annotation; - event_id: string; - key: string; - }; -}; +export type ReactionEventContent = MatrixReactionEventContent; export type MatrixSendResult = { messageId: string; @@ -85,9 +85,10 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { - cfg?: import("../../types.js").CoreConfig; - client?: import("@vector-im/matrix-bot-sdk").MatrixClient; + client?: import("../sdk.js").MatrixClient; + cfg?: CoreConfig; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; accountId?: string; replyToId?: string; threadId?: string | number | null; diff --git a/extensions/matrix/src/matrix/target-ids.ts b/extensions/matrix/src/matrix/target-ids.ts new file mode 100644 index 00000000000..8181c2b8b5c --- /dev/null +++ b/extensions/matrix/src/matrix/target-ids.ts @@ -0,0 +1,100 @@ +type MatrixTarget = { kind: "room"; id: string } | { kind: "user"; id: string }; +const MATRIX_PREFIX = "matrix:"; +const ROOM_PREFIX = "room:"; +const CHANNEL_PREFIX = "channel:"; +const USER_PREFIX = "user:"; + +function stripKnownPrefixes(raw: string, prefixes: readonly string[]): string { + let normalized = raw.trim(); + while (normalized) { + const lowered = normalized.toLowerCase(); + const matched = prefixes.find((prefix) => lowered.startsWith(prefix)); + if (!matched) { + return normalized; + } + normalized = normalized.slice(matched.length).trim(); + } + return normalized; +} + +export function resolveMatrixTargetIdentity(raw: string): MatrixTarget | null { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX]); + if (!normalized) { + return null; + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith(USER_PREFIX)) { + const id = normalized.slice(USER_PREFIX.length).trim(); + return id ? { kind: "user", id } : null; + } + if (lowered.startsWith(ROOM_PREFIX)) { + const id = normalized.slice(ROOM_PREFIX.length).trim(); + return id ? { kind: "room", id } : null; + } + if (lowered.startsWith(CHANNEL_PREFIX)) { + const id = normalized.slice(CHANNEL_PREFIX.length).trim(); + return id ? { kind: "room", id } : null; + } + if (isMatrixQualifiedUserId(normalized)) { + return { kind: "user", id: normalized }; + } + return { kind: "room", id: normalized }; +} + +export function isMatrixQualifiedUserId(raw: string): boolean { + const trimmed = raw.trim(); + return trimmed.startsWith("@") && trimmed.includes(":"); +} + +export function normalizeMatrixResolvableTarget(raw: string): string { + return stripKnownPrefixes(raw, [MATRIX_PREFIX, ROOM_PREFIX, CHANNEL_PREFIX]); +} + +export function normalizeMatrixMessagingTarget(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [ + MATRIX_PREFIX, + ROOM_PREFIX, + CHANNEL_PREFIX, + USER_PREFIX, + ]); + return normalized || undefined; +} + +export function normalizeMatrixDirectoryUserId(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX, USER_PREFIX]); + if (!normalized || normalized === "*") { + return undefined; + } + return isMatrixQualifiedUserId(normalized) ? `user:${normalized}` : normalized; +} + +export function normalizeMatrixDirectoryGroupId(raw: string): string | undefined { + const normalized = stripKnownPrefixes(raw, [MATRIX_PREFIX]); + if (!normalized || normalized === "*") { + return undefined; + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith(ROOM_PREFIX) || lowered.startsWith(CHANNEL_PREFIX)) { + return normalized; + } + if (normalized.startsWith("!")) { + return `room:${normalized}`; + } + return normalized; +} + +export function resolveMatrixDirectUserId(params: { + from?: string; + to?: string; + chatType?: string; +}): string | undefined { + if (params.chatType !== "direct") { + return undefined; + } + const roomId = normalizeMatrixResolvableTarget(params.to ?? ""); + if (!roomId.startsWith("!")) { + return undefined; + } + const userId = stripKnownPrefixes(params.from ?? "", [MATRIX_PREFIX, USER_PREFIX]); + return isMatrixQualifiedUserId(userId) ? userId : undefined; +} diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts new file mode 100644 index 00000000000..c872f720832 --- /dev/null +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -0,0 +1,574 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getSessionBindingService, + __testing, +} from "../../../../src/infra/outbound/session-binding-service.js"; +import { setMatrixRuntime } from "../runtime.js"; +import { resolveMatrixStoragePaths } from "./client/storage.js"; +import { + createMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./thread-bindings.js"; + +const pluginSdkActual = vi.hoisted(() => ({ + writeJsonFileAtomically: null as null | ((filePath: string, value: unknown) => Promise), +})); + +const sendMessageMatrixMock = vi.hoisted(() => + vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({ + messageId: opts?.threadId ? "$reply" : "$root", + roomId: "!room:example", + })), +); +const writeJsonFileAtomicallyMock = vi.hoisted(() => + vi.fn<(filePath: string, value: unknown) => Promise>(), +); + +vi.mock("openclaw/plugin-sdk/matrix", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/matrix", + ); + pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically; + return { + ...actual, + writeJsonFileAtomically: (filePath: string, value: unknown) => + writeJsonFileAtomicallyMock(filePath, value), + }; +}); + +vi.mock("./send.js", async () => { + const actual = await vi.importActual("./send.js"); + return { + ...actual, + sendMessageMatrix: sendMessageMatrixMock, + }; +}); + +describe("matrix thread bindings", () => { + let stateDir: string; + const auth = { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + } as const; + + function resolveBindingsFilePath() { + return path.join( + resolveMatrixStoragePaths({ + ...auth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + } + + async function readPersistedLastActivityAt(bindingsPath: string) { + const raw = await fs.readFile(bindingsPath, "utf-8"); + const parsed = JSON.parse(raw) as { + bindings?: Array<{ lastActivityAt?: number }>; + }; + return parsed.bindings?.[0]?.lastActivityAt; + } + + beforeEach(async () => { + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-thread-bindings-")); + __testing.resetSessionBindingAdaptersForTests(); + resetMatrixThreadBindingsForTests(); + sendMessageMatrixMock.mockClear(); + writeJsonFileAtomicallyMock.mockReset(); + writeJsonFileAtomicallyMock.mockImplementation(async (filePath: string, value: unknown) => { + await pluginSdkActual.writeJsonFileAtomically?.(filePath, value); + }); + setMatrixRuntime({ + state: { + resolveStateDir: () => stateDir, + }, + } as PluginRuntime); + }); + + it("creates child Matrix thread bindings from a top-level room context", async () => { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "!room:example", + }, + placement: "child", + metadata: { + introText: "intro root", + }, + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro root", { + client: {}, + accountId: "ops", + }); + expect(binding.conversation).toEqual({ + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + }); + }); + + it("posts intro messages inside existing Matrix threads for current placement", async () => { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + metadata: { + introText: "intro thread", + }, + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro thread", { + client: {}, + accountId: "ops", + threadId: "$thread", + }); + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toMatchObject({ + bindingId: binding.bindingId, + targetSessionKey: "agent:ops:subagent:child", + }); + }); + + it("expires idle bindings via the sweeper", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); + try { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 1_000, + maxAgeMs: 0, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + metadata: { + introText: "intro thread", + }, + }); + + sendMessageMatrixMock.mockClear(); + await vi.advanceTimersByTimeAsync(61_000); + await Promise.resolve(); + + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("persists a batch of expired bindings once per sweep", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); + try { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 1_000, + maxAgeMs: 0, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:first", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread-1", + parentConversationId: "!room:example", + }, + placement: "current", + }); + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:second", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread-2", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + writeJsonFileAtomicallyMock.mockClear(); + await vi.advanceTimersByTimeAsync(61_000); + + await vi.waitFor(() => { + expect(writeJsonFileAtomicallyMock).toHaveBeenCalledTimes(1); + }); + + await vi.waitFor(async () => { + const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8"); + expect(JSON.parse(persistedRaw)).toMatchObject({ + version: 1, + bindings: [], + }); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("logs and survives sweeper persistence failures", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z")); + const logVerboseMessage = vi.fn(); + try { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 1_000, + maxAgeMs: 0, + logVerboseMessage, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + writeJsonFileAtomicallyMock.mockClear(); + writeJsonFileAtomicallyMock.mockRejectedValueOnce(new Error("disk full")); + await vi.advanceTimersByTimeAsync(61_000); + + await vi.waitFor(() => { + expect(logVerboseMessage).toHaveBeenCalledWith( + expect.stringContaining("failed auto-unbinding expired bindings"), + ); + }); + + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("sends threaded farewell messages when bindings are unbound", async () => { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 1_000, + maxAgeMs: 0, + enableSweeper: false, + }); + + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + metadata: { + introText: "intro thread", + }, + }); + + sendMessageMatrixMock.mockClear(); + await getSessionBindingService().unbind({ + bindingId: binding.bindingId, + reason: "idle-expired", + }); + + expect(sendMessageMatrixMock).toHaveBeenCalledWith( + "room:!room:example", + expect.stringContaining("Session ended automatically"), + expect.objectContaining({ + accountId: "ops", + threadId: "$thread", + }), + ); + }); + + it("reloads persisted bindings after the Matrix access token changes", async () => { + const initialAuth = { + ...auth, + accessToken: "token-old", + }; + const rotatedAuth = { + ...auth, + accessToken: "token-new", + }; + + const initialManager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth: initialAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + initialManager.stop(); + resetMatrixThreadBindingsForTests(); + __testing.resetSessionBindingAdaptersForTests(); + + await createMatrixThreadBindingManager({ + accountId: "ops", + auth: rotatedAuth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toMatchObject({ + targetSessionKey: "agent:ops:subagent:child", + }); + + const initialBindingsPath = path.join( + resolveMatrixStoragePaths({ + ...initialAuth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + const rotatedBindingsPath = path.join( + resolveMatrixStoragePaths({ + ...rotatedAuth, + env: process.env, + }).rootDir, + "thread-bindings.json", + ); + expect(rotatedBindingsPath).toBe(initialBindingsPath); + }); + + it("updates lifecycle windows by session key and refreshes activity", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); + try { + const manager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + const original = manager.listBySessionKey("agent:ops:subagent:child")[0]; + expect(original).toBeDefined(); + + const idleUpdated = setMatrixThreadBindingIdleTimeoutBySessionKey({ + accountId: "ops", + targetSessionKey: "agent:ops:subagent:child", + idleTimeoutMs: 2 * 60 * 60 * 1000, + }); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + const maxAgeUpdated = setMatrixThreadBindingMaxAgeBySessionKey({ + accountId: "ops", + targetSessionKey: "agent:ops:subagent:child", + maxAgeMs: 6 * 60 * 60 * 1000, + }); + + expect(idleUpdated).toHaveLength(1); + expect(idleUpdated[0]?.metadata?.idleTimeoutMs).toBe(2 * 60 * 60 * 1000); + expect(maxAgeUpdated).toHaveLength(1); + expect(maxAgeUpdated[0]?.metadata?.maxAgeMs).toBe(6 * 60 * 60 * 1000); + expect(maxAgeUpdated[0]?.boundAt).toBe(original?.boundAt); + expect(maxAgeUpdated[0]?.metadata?.lastActivityAt).toBe( + Date.parse("2026-03-06T12:00:00.000Z"), + ); + expect(manager.listBySessionKey("agent:ops:subagent:child")[0]?.maxAgeMs).toBe( + 6 * 60 * 60 * 1000, + ); + expect(manager.listBySessionKey("agent:ops:subagent:child")[0]?.lastActivityAt).toBe( + Date.parse("2026-03-06T12:00:00.000Z"), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("persists the latest touched activity only after the debounce window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); + try { + await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + const bindingsPath = resolveBindingsFilePath(); + const originalLastActivityAt = await readPersistedLastActivityAt(bindingsPath); + const firstTouchedAt = Date.parse("2026-03-06T10:05:00.000Z"); + const secondTouchedAt = Date.parse("2026-03-06T10:10:00.000Z"); + + getSessionBindingService().touch(binding.bindingId, firstTouchedAt); + getSessionBindingService().touch(binding.bindingId, secondTouchedAt); + + await vi.advanceTimersByTimeAsync(29_000); + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(originalLastActivityAt); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(async () => { + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(secondTouchedAt); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("flushes pending touch persistence on stop", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); + try { + const manager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + const binding = await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + const touchedAt = Date.parse("2026-03-06T12:00:00.000Z"); + getSessionBindingService().touch(binding.bindingId, touchedAt); + + manager.stop(); + vi.useRealTimers(); + + const bindingsPath = resolveBindingsFilePath(); + await vi.waitFor(async () => { + expect(await readPersistedLastActivityAt(bindingsPath)).toBe(touchedAt); + }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts new file mode 100644 index 00000000000..d3d8f5bf304 --- /dev/null +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -0,0 +1,755 @@ +import path from "node:path"; +import { + readJsonFileWithFallback, + registerSessionBindingAdapter, + resolveAgentIdFromSessionKey, + resolveThreadBindingFarewellText, + unregisterSessionBindingAdapter, + writeJsonFileAtomically, + type BindingTargetKind, + type SessionBindingRecord, +} from "openclaw/plugin-sdk/matrix"; +import { resolveMatrixStoragePaths } from "./client/storage.js"; +import type { MatrixAuth } from "./client/types.js"; +import type { MatrixClient } from "./sdk.js"; +import { sendMessageMatrix } from "./send.js"; + +const STORE_VERSION = 1; +const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000; +const TOUCH_PERSIST_DELAY_MS = 30_000; + +type MatrixThreadBindingTargetKind = "subagent" | "acp"; + +type MatrixThreadBindingRecord = { + accountId: string; + conversationId: string; + parentConversationId?: string; + targetKind: MatrixThreadBindingTargetKind; + targetSessionKey: string; + agentId?: string; + label?: string; + boundBy?: string; + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +type StoredMatrixThreadBindingState = { + version: number; + bindings: MatrixThreadBindingRecord[]; +}; + +export type MatrixThreadBindingManager = { + accountId: string; + getIdleTimeoutMs: () => number; + getMaxAgeMs: () => number; + getByConversation: (params: { + conversationId: string; + parentConversationId?: string; + }) => MatrixThreadBindingRecord | undefined; + listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; + listBindings: () => MatrixThreadBindingRecord[]; + touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; + setIdleTimeoutBySessionKey: (params: { + targetSessionKey: string; + idleTimeoutMs: number; + }) => MatrixThreadBindingRecord[]; + setMaxAgeBySessionKey: (params: { + targetSessionKey: string; + maxAgeMs: number; + }) => MatrixThreadBindingRecord[]; + stop: () => void; +}; + +const MANAGERS_BY_ACCOUNT_ID = new Map(); +const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); + +function normalizeDurationMs(raw: unknown, fallback: number): number { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return fallback; + } + return Math.max(0, Math.floor(raw)); +} + +function normalizeText(raw: unknown): string { + return typeof raw === "string" ? raw.trim() : ""; +} + +function normalizeConversationId(raw: unknown): string | undefined { + const trimmed = normalizeText(raw); + return trimmed || undefined; +} + +function resolveBindingKey(params: { + accountId: string; + conversationId: string; + parentConversationId?: string; +}): string { + return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; +} + +function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { + return raw === "subagent" ? "subagent" : "session"; +} + +function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { + return raw === "subagent" ? "subagent" : "acp"; +} + +function resolveEffectiveBindingExpiry(params: { + record: MatrixThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +function toSessionBindingRecord( + record: MatrixThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { + const lifecycle = resolveEffectiveBindingExpiry({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }); + const idleTimeoutMs = + typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; + const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; + return { + bindingId: resolveBindingKey(record), + targetSessionKey: record.targetSessionKey, + targetKind: toSessionBindingTargetKind(record.targetKind), + conversation: { + channel: "matrix", + accountId: record.accountId, + conversationId: record.conversationId, + parentConversationId: record.parentConversationId, + }, + status: "active", + boundAt: record.boundAt, + expiresAt: lifecycle.expiresAt, + metadata: { + agentId: record.agentId, + label: record.label, + boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs, + maxAgeMs, + }, + }; +} + +function resolveBindingsPath(params: { + auth: MatrixAuth; + accountId: string; + env?: NodeJS.ProcessEnv; +}): string { + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.accountId, + deviceId: params.auth.deviceId, + env: params.env, + }); + return path.join(storagePaths.rootDir, "thread-bindings.json"); +} + +async function loadBindingsFromDisk(filePath: string, accountId: string) { + const { value } = await readJsonFileWithFallback( + filePath, + null, + ); + if (value?.version !== STORE_VERSION || !Array.isArray(value.bindings)) { + return []; + } + const loaded: MatrixThreadBindingRecord[] = []; + for (const entry of value.bindings) { + const conversationId = normalizeConversationId(entry?.conversationId); + const parentConversationId = normalizeConversationId(entry?.parentConversationId); + const targetSessionKey = normalizeText(entry?.targetSessionKey); + if (!conversationId || !targetSessionKey) { + continue; + } + const boundAt = + typeof entry?.boundAt === "number" && Number.isFinite(entry.boundAt) + ? Math.floor(entry.boundAt) + : Date.now(); + const lastActivityAt = + typeof entry?.lastActivityAt === "number" && Number.isFinite(entry.lastActivityAt) + ? Math.floor(entry.lastActivityAt) + : boundAt; + loaded.push({ + accountId, + conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + targetKind: entry?.targetKind === "subagent" ? "subagent" : "acp", + targetSessionKey, + agentId: normalizeText(entry?.agentId) || undefined, + label: normalizeText(entry?.label) || undefined, + boundBy: normalizeText(entry?.boundBy) || undefined, + boundAt, + lastActivityAt: Math.max(lastActivityAt, boundAt), + idleTimeoutMs: + typeof entry?.idleTimeoutMs === "number" && Number.isFinite(entry.idleTimeoutMs) + ? Math.max(0, Math.floor(entry.idleTimeoutMs)) + : undefined, + maxAgeMs: + typeof entry?.maxAgeMs === "number" && Number.isFinite(entry.maxAgeMs) + ? Math.max(0, Math.floor(entry.maxAgeMs)) + : undefined, + }); + } + return loaded; +} + +function toStoredBindingsState( + bindings: MatrixThreadBindingRecord[], +): StoredMatrixThreadBindingState { + return { + version: STORE_VERSION, + bindings: [...bindings].sort((a, b) => a.boundAt - b.boundAt), + }; +} + +async function persistBindingsSnapshot( + filePath: string, + bindings: MatrixThreadBindingRecord[], +): Promise { + await writeJsonFileAtomically(filePath, toStoredBindingsState(bindings)); +} + +function setBindingRecord(record: MatrixThreadBindingRecord): void { + BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); +} + +function removeBindingRecord(record: MatrixThreadBindingRecord): MatrixThreadBindingRecord | null { + const key = resolveBindingKey(record); + const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + if (removed) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + } + return removed; +} + +function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { + return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === accountId, + ); +} + +function buildMatrixBindingIntroText(params: { + metadata?: Record; + targetSessionKey: string; +}): string { + const introText = normalizeText(params.metadata?.introText); + if (introText) { + return introText; + } + const label = normalizeText(params.metadata?.label); + const agentId = + normalizeText(params.metadata?.agentId) || + resolveAgentIdFromSessionKey(params.targetSessionKey); + const base = label || agentId || "session"; + return `⚙️ ${base} session active. Messages here go directly to this session.`; +} + +async function sendBindingMessage(params: { + client: MatrixClient; + accountId: string; + roomId: string; + threadId?: string; + text: string; +}): Promise { + const trimmed = params.text.trim(); + if (!trimmed) { + return null; + } + const result = await sendMessageMatrix(`room:${params.roomId}`, trimmed, { + client: params.client, + accountId: params.accountId, + ...(params.threadId ? { threadId: params.threadId } : {}), + }); + return result.messageId || null; +} + +async function sendFarewellMessage(params: { + client: MatrixClient; + accountId: string; + record: MatrixThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; + reason?: string; +}): Promise { + const roomId = params.record.parentConversationId ?? params.record.conversationId; + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? params.record.idleTimeoutMs + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" ? params.record.maxAgeMs : params.defaultMaxAgeMs; + const farewellText = resolveThreadBindingFarewellText({ + reason: params.reason, + idleTimeoutMs, + maxAgeMs, + }); + await sendBindingMessage({ + client: params.client, + accountId: params.accountId, + roomId, + threadId: + params.record.parentConversationId && + params.record.parentConversationId !== params.record.conversationId + ? params.record.conversationId + : undefined, + text: farewellText, + }).catch(() => {}); +} + +export async function createMatrixThreadBindingManager(params: { + accountId: string; + auth: MatrixAuth; + client: MatrixClient; + env?: NodeJS.ProcessEnv; + idleTimeoutMs: number; + maxAgeMs: number; + enableSweeper?: boolean; + logVerboseMessage?: (message: string) => void; +}): Promise { + if (params.auth.accountId !== params.accountId) { + throw new Error( + `Matrix thread binding account mismatch: requested ${params.accountId}, auth resolved ${params.auth.accountId}`, + ); + } + const existing = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + if (existing) { + return existing; + } + + const filePath = resolveBindingsPath({ + auth: params.auth, + accountId: params.accountId, + env: params.env, + }); + const loaded = await loadBindingsFromDisk(filePath, params.accountId); + for (const record of loaded) { + setBindingRecord(record); + } + + let persistQueue: Promise = Promise.resolve(); + const enqueuePersist = (bindings?: MatrixThreadBindingRecord[]) => { + const snapshot = bindings ?? listBindingsForAccount(params.accountId); + const next = persistQueue + .catch(() => {}) + .then(async () => { + await persistBindingsSnapshot(filePath, snapshot); + }); + persistQueue = next; + return next; + }; + const persist = async () => await enqueuePersist(); + const persistSafely = (reason: string, bindings?: MatrixThreadBindingRecord[]) => { + void enqueuePersist(bindings).catch((err) => { + params.logVerboseMessage?.( + `matrix: failed persisting thread bindings account=${params.accountId} action=${reason}: ${String(err)}`, + ); + }); + }; + const defaults = { + idleTimeoutMs: params.idleTimeoutMs, + maxAgeMs: params.maxAgeMs, + }; + let persistTimer: NodeJS.Timeout | null = null; + const schedulePersist = (delayMs: number) => { + if (persistTimer) { + return; + } + persistTimer = setTimeout(() => { + persistTimer = null; + persistSafely("delayed-touch"); + }, delayMs); + persistTimer.unref?.(); + }; + const updateBindingsBySessionKey = (input: { + targetSessionKey: string; + update: (entry: MatrixThreadBindingRecord, now: number) => MatrixThreadBindingRecord; + persistReason: string; + }): MatrixThreadBindingRecord[] => { + const targetSessionKey = input.targetSessionKey.trim(); + if (!targetSessionKey) { + return []; + } + const now = Date.now(); + const nextBindings = listBindingsForAccount(params.accountId) + .filter((entry) => entry.targetSessionKey === targetSessionKey) + .map((entry) => input.update(entry, now)); + if (nextBindings.length === 0) { + return []; + } + for (const entry of nextBindings) { + setBindingRecord(entry); + } + persistSafely(input.persistReason); + return nextBindings; + }; + + const manager: MatrixThreadBindingManager = { + accountId: params.accountId, + getIdleTimeoutMs: () => defaults.idleTimeoutMs, + getMaxAgeMs: () => defaults.maxAgeMs, + getByConversation: ({ conversationId, parentConversationId }) => + listBindingsForAccount(params.accountId).find((entry) => { + if (entry.conversationId !== conversationId.trim()) { + return false; + } + if (!parentConversationId) { + return true; + } + return (entry.parentConversationId ?? "") === parentConversationId.trim(); + }), + listBySessionKey: (targetSessionKey) => + listBindingsForAccount(params.accountId).filter( + (entry) => entry.targetSessionKey === targetSessionKey.trim(), + ), + listBindings: () => listBindingsForAccount(params.accountId), + touchBinding: (bindingId, at) => { + const record = listBindingsForAccount(params.accountId).find( + (entry) => resolveBindingKey(entry) === bindingId.trim(), + ); + if (!record) { + return null; + } + const nextRecord = { + ...record, + lastActivityAt: + typeof at === "number" && Number.isFinite(at) + ? Math.max(record.lastActivityAt, Math.floor(at)) + : Date.now(), + }; + setBindingRecord(nextRecord); + schedulePersist(TOUCH_PERSIST_DELAY_MS); + return nextRecord; + }, + setIdleTimeoutBySessionKey: ({ targetSessionKey, idleTimeoutMs }) => { + return updateBindingsBySessionKey({ + targetSessionKey, + persistReason: "idle-timeout-update", + update: (entry, now) => ({ + ...entry, + idleTimeoutMs: Math.max(0, Math.floor(idleTimeoutMs)), + lastActivityAt: now, + }), + }); + }, + setMaxAgeBySessionKey: ({ targetSessionKey, maxAgeMs }) => { + return updateBindingsBySessionKey({ + targetSessionKey, + persistReason: "max-age-update", + update: (entry, now) => ({ + ...entry, + maxAgeMs: Math.max(0, Math.floor(maxAgeMs)), + lastActivityAt: now, + }), + }); + }, + stop: () => { + if (sweepTimer) { + clearInterval(sweepTimer); + } + if (persistTimer) { + clearTimeout(persistTimer); + persistTimer = null; + persistSafely("shutdown-flush"); + } + unregisterSessionBindingAdapter({ + channel: "matrix", + accountId: params.accountId, + }); + if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId) === manager) { + MANAGERS_BY_ACCOUNT_ID.delete(params.accountId); + } + for (const record of listBindingsForAccount(params.accountId)) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(resolveBindingKey(record)); + } + }, + }; + + let sweepTimer: NodeJS.Timeout | null = null; + const removeRecords = (records: MatrixThreadBindingRecord[]) => { + if (records.length === 0) { + return []; + } + return records + .map((record) => removeBindingRecord(record)) + .filter((record): record is MatrixThreadBindingRecord => Boolean(record)); + }; + const sendFarewellMessages = async ( + removed: MatrixThreadBindingRecord[], + reason: string | ((record: MatrixThreadBindingRecord) => string | undefined), + ) => { + await Promise.all( + removed.map(async (record) => { + await sendFarewellMessage({ + client: params.client, + accountId: params.accountId, + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + reason: typeof reason === "function" ? reason(record) : reason, + }); + }), + ); + }; + const unbindRecords = async (records: MatrixThreadBindingRecord[], reason: string) => { + const removed = removeRecords(records); + if (removed.length === 0) { + return []; + } + await persist(); + await sendFarewellMessages(removed, reason); + return removed.map((record) => toSessionBindingRecord(record, defaults)); + }; + + registerSessionBindingAdapter({ + channel: "matrix", + accountId: params.accountId, + capabilities: { placements: ["current", "child"], bindSupported: true, unbindSupported: true }, + bind: async (input) => { + const conversationId = input.conversation.conversationId.trim(); + const parentConversationId = input.conversation.parentConversationId?.trim() || undefined; + const targetSessionKey = input.targetSessionKey.trim(); + if (!conversationId || !targetSessionKey) { + return null; + } + + let boundConversationId = conversationId; + let boundParentConversationId = parentConversationId; + const introText = buildMatrixBindingIntroText({ + metadata: input.metadata, + targetSessionKey, + }); + + if (input.placement === "child") { + const roomId = parentConversationId || conversationId; + const rootEventId = await sendBindingMessage({ + client: params.client, + accountId: params.accountId, + roomId, + text: introText, + }); + if (!rootEventId) { + return null; + } + boundConversationId = rootEventId; + boundParentConversationId = roomId; + } + + const now = Date.now(); + const record: MatrixThreadBindingRecord = { + accountId: params.accountId, + conversationId: boundConversationId, + ...(boundParentConversationId ? { parentConversationId: boundParentConversationId } : {}), + targetKind: toMatrixBindingTargetKind(input.targetKind), + targetSessionKey, + agentId: + normalizeText(input.metadata?.agentId) || resolveAgentIdFromSessionKey(targetSessionKey), + label: normalizeText(input.metadata?.label) || undefined, + boundBy: normalizeText(input.metadata?.boundBy) || "system", + boundAt: now, + lastActivityAt: now, + idleTimeoutMs: defaults.idleTimeoutMs, + maxAgeMs: defaults.maxAgeMs, + }; + setBindingRecord(record); + await persist(); + + if (input.placement === "current" && introText) { + const roomId = boundParentConversationId || boundConversationId; + const threadId = + boundParentConversationId && boundParentConversationId !== boundConversationId + ? boundConversationId + : undefined; + await sendBindingMessage({ + client: params.client, + accountId: params.accountId, + roomId, + threadId, + text: introText, + }).catch(() => {}); + } + + return toSessionBindingRecord(record, defaults); + }, + listBySession: (targetSessionKey) => + manager + .listBySessionKey(targetSessionKey) + .map((record) => toSessionBindingRecord(record, defaults)), + resolveByConversation: (ref) => { + const record = manager.getByConversation({ + conversationId: ref.conversationId, + parentConversationId: ref.parentConversationId, + }); + return record ? toSessionBindingRecord(record, defaults) : null; + }, + setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) => + manager + .setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs }) + .map((record) => toSessionBindingRecord(record, defaults)), + setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) => + manager + .setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs }) + .map((record) => toSessionBindingRecord(record, defaults)), + touch: (bindingId, at) => { + manager.touchBinding(bindingId, at); + }, + unbind: async (input) => { + const removed = await unbindRecords( + listBindingsForAccount(params.accountId).filter((record) => { + if (input.bindingId?.trim()) { + return resolveBindingKey(record) === input.bindingId.trim(); + } + if (input.targetSessionKey?.trim()) { + return record.targetSessionKey === input.targetSessionKey.trim(); + } + return false; + }), + input.reason, + ); + return removed; + }, + }); + + if (params.enableSweeper !== false) { + sweepTimer = setInterval(() => { + const now = Date.now(); + const expired = listBindingsForAccount(params.accountId) + .map((record) => ({ + record, + lifecycle: resolveEffectiveBindingExpiry({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }), + })) + .filter( + ( + entry, + ): entry is { + record: MatrixThreadBindingRecord; + lifecycle: { expiresAt: number; reason: "idle-expired" | "max-age-expired" }; + } => + typeof entry.lifecycle.expiresAt === "number" && + entry.lifecycle.expiresAt <= now && + Boolean(entry.lifecycle.reason), + ); + if (expired.length === 0) { + return; + } + const reasonByBindingKey = new Map( + expired.map(({ record, lifecycle }) => [resolveBindingKey(record), lifecycle.reason]), + ); + void (async () => { + const removed = removeRecords(expired.map(({ record }) => record)); + if (removed.length === 0) { + return; + } + for (const record of removed) { + const reason = reasonByBindingKey.get(resolveBindingKey(record)); + params.logVerboseMessage?.( + `matrix: auto-unbinding ${record.conversationId} due to ${reason}`, + ); + } + await persist(); + await sendFarewellMessages(removed, (record) => + reasonByBindingKey.get(resolveBindingKey(record)), + ); + })().catch((err) => { + params.logVerboseMessage?.( + `matrix: failed auto-unbinding expired bindings account=${params.accountId}: ${String(err)}`, + ); + }); + }, THREAD_BINDINGS_SWEEP_INTERVAL_MS); + sweepTimer.unref?.(); + } + + MANAGERS_BY_ACCOUNT_ID.set(params.accountId, manager); + return manager; +} + +export function getMatrixThreadBindingManager( + accountId: string, +): MatrixThreadBindingManager | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null; +} + +export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { + accountId: string; + targetSessionKey: string; + idleTimeoutMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + if (!manager) { + return []; + } + return manager.setIdleTimeoutBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function setMatrixThreadBindingMaxAgeBySessionKey(params: { + accountId: string; + targetSessionKey: string; + maxAgeMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + if (!manager) { + return []; + } + return manager.setMaxAgeBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function resetMatrixThreadBindingsForTests(): void { + for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + manager.stop(); + } + MANAGERS_BY_ACCOUNT_ID.clear(); + BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); +} diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts new file mode 100644 index 00000000000..f1d610aa5d4 --- /dev/null +++ b/extensions/matrix/src/onboarding.resolve.test.ts @@ -0,0 +1,112 @@ +import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "./types.js"; + +const resolveMatrixTargetsMock = vi.hoisted(() => + vi.fn(async () => [{ input: "Alice", resolved: true, id: "@alice:example.org" }]), +); + +vi.mock("./resolve-targets.js", () => ({ + resolveMatrixTargets: resolveMatrixTargetsMock, +})); + +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { setMatrixRuntime } from "./runtime.js"; + +describe("matrix onboarding account-scoped resolution", () => { + beforeEach(() => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + resolveMatrixTargetsMock.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("passes accountId into Matrix allowlist target resolution during onboarding", async () => { + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + if (message === "Matrix rooms access") { + return "allowlist"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + if (message === "Matrix homeserver URL") { + return "https://matrix.ops.example.org"; + } + if (message === "Matrix access token") { + return "ops-token"; + } + if (message === "Matrix device name (optional)") { + return ""; + } + if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { + return "Alice"; + } + if (message === "Matrix rooms allowlist (comma-separated)") { + return ""; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enable end-to-end encryption (E2EE)?") { + return false; + } + if (message === "Configure Matrix rooms access?") { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: true, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + expect(resolveMatrixTargetsMock).toHaveBeenCalledWith({ + cfg: expect.any(Object), + accountId: "ops", + inputs: ["Alice"], + kind: "user", + }); + }); +}); diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts new file mode 100644 index 00000000000..2107fa2ec05 --- /dev/null +++ b/extensions/matrix/src/onboarding.test.ts @@ -0,0 +1,476 @@ +import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { setMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +vi.mock("./matrix/deps.js", () => ({ + ensureMatrixSdkInstalled: vi.fn(async () => {}), + isMatrixSdkAvailable: vi.fn(() => true), +})); + +describe("matrix onboarding", () => { + 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_DEVICE_ID: process.env.MATRIX_DEVICE_ID, + MATRIX_DEVICE_NAME: process.env.MATRIX_DEVICE_NAME, + MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER, + MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, + }; + + afterEach(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("offers env shortcut for non-default account when scoped env vars are present", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + process.env.MATRIX_HOMESERVER = "https://matrix.env.example.org"; + process.env.MATRIX_USER_ID = "@env:example.org"; + process.env.MATRIX_PASSWORD = "env-password"; // pragma: allowlist secret + process.env.MATRIX_ACCESS_TOKEN = ""; + process.env.MATRIX_OPS_HOMESERVER = "https://matrix.ops.env.example.org"; + process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token"; + + const confirmMessages: string[] = []; + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + confirmMessages.push(message); + if (message.startsWith("Matrix env vars detected")) { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: false, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + if (result !== "skip") { + expect(result.accountId).toBe("ops"); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops).toMatchObject({ + enabled: true, + }); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops?.homeserver).toBeUndefined(); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops?.accessToken).toBeUndefined(); + } + expect( + confirmMessages.some((message) => + message.startsWith( + "Matrix env vars detected (MATRIX_OPS_HOMESERVER (+ auth vars)). Use env values?", + ), + ), + ).toBe(true); + }); + + it("promotes legacy top-level Matrix config before adding a named account", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + if (message === "Matrix homeserver URL") { + return "https://matrix.ops.example.org"; + } + if (message === "Matrix access token") { + return "ops-token"; + } + if (message === "Matrix device name (optional)") { + return ""; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async () => false), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.main.example.org", + userId: "@main:example.org", + accessToken: "main-token", + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: false, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + if (result === "skip") { + return; + } + + expect(result.cfg.channels?.matrix?.homeserver).toBeUndefined(); + expect(result.cfg.channels?.matrix?.accessToken).toBeUndefined(); + expect(result.cfg.channels?.matrix?.accounts?.default).toMatchObject({ + homeserver: "https://matrix.main.example.org", + userId: "@main:example.org", + accessToken: "main-token", + }); + expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({ + name: "ops", + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }); + }); + + it("includes device env var names in auth help text", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const notes: string[] = []; + const prompter = { + note: vi.fn(async (message: unknown) => { + notes.push(String(message)); + }), + text: vi.fn(async () => { + throw new Error("stop-after-help"); + }), + confirm: vi.fn(async () => false), + select: vi.fn(async () => "token"), + } as unknown as WizardPrompter; + + await expect( + matrixOnboardingAdapter.configureInteractive!({ + cfg: { channels: {} } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + configured: false, + label: "Matrix", + }), + ).rejects.toThrow("stop-after-help"); + + const noteText = notes.join("\n"); + expect(noteText).toContain("MATRIX_DEVICE_ID"); + expect(noteText).toContain("MATRIX_DEVICE_NAME"); + expect(noteText).toContain("MATRIX__DEVICE_ID"); + expect(noteText).toContain("MATRIX__DEVICE_NAME"); + }); + + it("resolves status using the overridden Matrix account", async () => { + const status = await matrixOnboardingAdapter.getStatus({ + cfg: { + channels: { + matrix: { + defaultAccount: "default", + accounts: { + default: { + homeserver: "https://matrix.default.example.org", + }, + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig, + options: undefined, + accountOverrides: { + matrix: "ops", + }, + }); + + expect(status.configured).toBe(true); + expect(status.selectionHint).toBe("configured"); + expect(status.statusLines).toEqual(["Matrix: configured"]); + }); + + it("writes allowlists and room access to the selected Matrix account", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + if (message === "Matrix rooms access") { + return "allowlist"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + if (message === "Matrix homeserver URL") { + return "https://matrix.ops.example.org"; + } + if (message === "Matrix access token") { + return "ops-token"; + } + if (message === "Matrix device name (optional)") { + return "Ops Gateway"; + } + if (message === "Matrix allowFrom (full @user:server; display name only if unique)") { + return "@alice:example.org"; + } + if (message === "Matrix rooms allowlist (comma-separated)") { + return "!ops-room:example.org"; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enable end-to-end encryption (E2EE)?") { + return false; + } + if (message === "Configure Matrix rooms access?") { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: true, + configured: true, + label: "Matrix", + }); + + expect(result).not.toBe("skip"); + if (result === "skip") { + return; + } + + expect(result.accountId).toBe("ops"); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops).toMatchObject({ + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + deviceName: "Ops Gateway", + dm: { + policy: "allowlist", + allowFrom: ["@alice:example.org"], + }, + groupPolicy: "allowlist", + groups: { + "!ops-room:example.org": { allow: true }, + }, + }); + expect(result.cfg.channels?.["matrix"]?.dm).toBeUndefined(); + expect(result.cfg.channels?.["matrix"]?.groups).toBeUndefined(); + }); + + it("reports account-scoped DM config keys for named accounts", () => { + const resolveConfigKeys = matrixOnboardingAdapter.dmPolicy?.resolveConfigKeys; + expect(resolveConfigKeys).toBeDefined(); + if (!resolveConfigKeys) { + return; + } + + expect( + resolveConfigKeys( + { + channels: { + matrix: { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + }, + ops: { + homeserver: "https://matrix.ops.example.org", + }, + }, + }, + }, + } as CoreConfig, + "ops", + ), + ).toEqual({ + policyKey: "channels.matrix.accounts.ops.dm.policy", + allowFromKey: "channels.matrix.accounts.ops.dm.allowFrom", + }); + }); + + it("reports configured when only the effective default Matrix account is configured", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const status = await matrixOnboardingAdapter.getStatus({ + cfg: { + channels: { + matrix: { + defaultAccount: "ops", + accounts: { + ops: { + homeserver: "https://matrix.ops.example.org", + accessToken: "ops-token", + }, + }, + }, + }, + } as CoreConfig, + accountOverrides: {}, + }); + + expect(status.configured).toBe(true); + expect(status.statusLines).toContain("Matrix: configured"); + expect(status.selectionHint).toBe("configured"); + }); + + it("asks for defaultAccount when multiple named Matrix accounts exist", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const status = await matrixOnboardingAdapter.getStatus({ + 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, + accountOverrides: {}, + }); + + expect(status.configured).toBe(false); + expect(status.statusLines).toEqual([ + 'Matrix: set "channels.matrix.defaultAccount" to select a named account', + ]); + expect(status.selectionHint).toBe("set defaultAccount"); + }); +}); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts new file mode 100644 index 00000000000..b79dc8ede33 --- /dev/null +++ b/extensions/matrix/src/onboarding.ts @@ -0,0 +1,578 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; +import { + addWildcardAllowFrom, + formatDocsLink, + mergeAllowFromEntries, + moveSingleAccountChannelSectionToDefaultAccount, + normalizeAccountId, + promptChannelAccessConfig, + promptAccountId, + type RuntimeEnv, + type WizardPrompter, +} from "openclaw/plugin-sdk/matrix"; +import { + type ChannelSetupDmPolicy, + type ChannelSetupWizardAdapter, +} from "openclaw/plugin-sdk/setup"; +import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + resolveMatrixAccountConfig, +} from "./matrix/accounts.js"; +import { resolveMatrixEnvAuthReadiness, validateMatrixHomeserverUrl } from "./matrix/client.js"; +import { + resolveMatrixConfigFieldPath, + resolveMatrixConfigPath, + updateMatrixAccountConfig, +} from "./matrix/config-update.js"; +import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; +import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string { + return normalizeAccountId( + accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID, + ); +} + +function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy, accountId?: string) { + const resolvedAccountId = resolveMatrixOnboardingAccountId(cfg, accountId); + const existing = resolveMatrixAccountConfig({ + cfg, + accountId: resolvedAccountId, + }); + const allowFrom = policy === "open" ? addWildcardAllowFrom(existing.dm?.allowFrom) : undefined; + return updateMatrixAccountConfig(cfg, resolvedAccountId, { + dm: { + ...existing.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }); +} + +async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Matrix requires a homeserver URL.", + "Use an access token (recommended) or password login to an existing account.", + "With access token: user ID is fetched automatically.", + "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD, MATRIX_DEVICE_ID, MATRIX_DEVICE_NAME.", + "Per-account env vars: MATRIX__HOMESERVER, MATRIX__USER_ID, MATRIX__ACCESS_TOKEN, MATRIX__PASSWORD, MATRIX__DEVICE_ID, MATRIX__DEVICE_NAME.", + `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, + ].join("\n"), + "Matrix setup", + ); +} + +async function promptMatrixAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const { cfg, prompter } = params; + const accountId = resolveMatrixOnboardingAccountId(cfg, params.accountId); + const existingConfig = resolveMatrixAccountConfig({ cfg, accountId }); + const existingAllowFrom = existingConfig.dm?.allowFrom ?? []; + const account = resolveMatrixAccount({ cfg, accountId }); + const canResolve = Boolean(account.configured); + + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (full @user:server; display name only if unique)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + const pending: string[] = []; + const unresolved: string[] = []; + const unresolvedNotes: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + pending.push(part); + } + + if (pending.length > 0) { + const results = await resolveMatrixTargets({ + cfg, + accountId, + inputs: pending, + kind: "user", + }).catch(() => []); + for (const result of results) { + if (result?.resolved && result.id) { + resolvedIds.push(result.id); + continue; + } + if (result?.input) { + unresolved.push(result.input); + if (result.note) { + unresolvedNotes.push(`${result.input}: ${result.note}`); + } + } + } + } + + if (unresolved.length > 0) { + const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; + await prompter.note( + `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); + return updateMatrixAccountConfig(cfg, accountId, { + dm: { + ...existingConfig.dm, + policy: "allowlist", + allowFrom: unique, + }, + }); + } +} + +function setMatrixGroupPolicy( + cfg: CoreConfig, + groupPolicy: "open" | "allowlist" | "disabled", + accountId?: string, +) { + return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), { + groupPolicy, + }); +} + +function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) { + const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); + return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), { + groups, + rooms: null, + }); +} + +const dmPolicy: ChannelSetupDmPolicy = { + label: "Matrix", + channel, + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + resolveConfigKeys: (cfg, accountId) => { + const effectiveAccountId = resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId); + return { + policyKey: resolveMatrixConfigFieldPath(cfg as CoreConfig, effectiveAccountId, "dm.policy"), + allowFromKey: resolveMatrixConfigFieldPath( + cfg as CoreConfig, + effectiveAccountId, + "dm.allowFrom", + ), + }; + }, + getCurrent: (cfg, accountId) => + resolveMatrixAccountConfig({ + cfg: cfg as CoreConfig, + accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId), + }).dm?.policy ?? "pairing", + setPolicy: (cfg, policy, accountId) => setMatrixDmPolicy(cfg as CoreConfig, policy, accountId), + promptAllowFrom: promptMatrixAllowFrom, +}; + +type MatrixConfigureIntent = "update" | "add-account"; + +async function runMatrixConfigure(params: { + cfg: CoreConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + forceAllowFrom: boolean; + accountOverrides?: Partial>; + shouldPromptAccountIds?: boolean; + intent: MatrixConfigureIntent; +}): Promise<{ cfg: CoreConfig; accountId: string }> { + let next = params.cfg; + await ensureMatrixSdkInstalled({ + runtime: params.runtime, + confirm: async (message) => + await params.prompter.confirm({ + message, + initialValue: true, + }), + }); + const defaultAccountId = resolveDefaultMatrixAccountId(next); + let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID; + if (params.intent === "add-account") { + const enteredName = String( + await params.prompter.text({ + message: "Matrix account name", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + accountId = normalizeAccountId(enteredName); + if (enteredName !== accountId) { + await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); + } + if (accountId !== DEFAULT_ACCOUNT_ID) { + next = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: next, + channelKey: channel, + }) as CoreConfig; + } + next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); + } else { + const override = params.accountOverrides?.[channel]?.trim(); + if (override) { + accountId = normalizeAccountId(override); + } else if (params.shouldPromptAccountIds) { + accountId = await promptAccountId({ + cfg: next, + prompter: params.prompter, + label: "Matrix", + currentId: accountId, + listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig), + defaultAccountId, + }); + } + } + + const existing = resolveMatrixAccountConfig({ cfg: next, accountId }); + const account = resolveMatrixAccount({ cfg: next, accountId }); + if (!account.configured) { + await noteMatrixAuthHelp(params.prompter); + } + + const envReadiness = resolveMatrixEnvAuthReadiness(accountId, process.env); + const envReady = envReadiness.ready; + const envHomeserver = envReadiness.homeserver; + const envUserId = envReadiness.userId; + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const useEnv = await params.prompter.confirm({ + message: `Matrix env vars detected (${envReadiness.sourceHint}). Use env values?`, + initialValue: true, + }); + if (useEnv) { + next = updateMatrixAccountConfig(next, accountId, { enabled: true }); + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ + cfg: next, + prompter: params.prompter, + accountId, + }); + } + return { cfg: next, accountId }; + } + } + + const homeserver = String( + await params.prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + try { + validateMatrixHomeserverUrl(String(value ?? "")); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : "Invalid Matrix homeserver URL"; + } + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = typeof existing.password === "string" ? existing.password : ""; + let userId = existing.userId ?? ""; + + if (accessToken || password) { + const keep = await params.prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + } + } + + if (!accessToken && !password) { + const authMode = await params.prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + ], + }); + + if (authMode === "token") { + accessToken = String( + await params.prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + userId = ""; + } else { + userId = String( + await params.prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!raw.startsWith("@")) { + return "Matrix user IDs should start with @"; + } + if (!raw.includes(":")) { + return "Matrix user IDs should include a server (:server)"; + } + return undefined; + }, + }), + ).trim(); + password = String( + await params.prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const deviceName = String( + await params.prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + const enableEncryption = await params.prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = updateMatrixAccountConfig(next, accountId, { + enabled: true, + homeserver, + userId: userId || null, + accessToken: accessToken || null, + password: password || null, + deviceName: deviceName || null, + encryption: enableEncryption, + }); + + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ + cfg: next, + prompter: params.prompter, + accountId, + }); + } + + const existingAccountConfig = resolveMatrixAccountConfig({ cfg: next, accountId }); + const existingGroups = existingAccountConfig.groups ?? existingAccountConfig.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter: params.prompter, + label: "Matrix rooms", + currentPolicy: existingAccountConfig.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy, accountId); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + accountId, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await params.prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist", accountId); + next = setMatrixGroupRooms(next, roomKeys, accountId); + } + } + + return { cfg: next, accountId }; +} + +export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = { + channel, + getStatus: async ({ cfg, accountOverrides }) => { + const resolvedCfg = cfg as CoreConfig; + const sdkReady = isMatrixSdkAvailable(); + if (!accountOverrides[channel] && requiresExplicitMatrixDefaultAccount(resolvedCfg)) { + return { + channel, + configured: false, + statusLines: ['Matrix: set "channels.matrix.defaultAccount" to select a named account'], + selectionHint: !sdkReady ? "install matrix-js-sdk" : "set defaultAccount", + }; + } + const account = resolveMatrixAccount({ + cfg: resolvedCfg, + accountId: resolveMatrixOnboardingAccountId(resolvedCfg, accountOverrides[channel]), + }); + const configured = account.configured; + return { + channel, + configured, + statusLines: [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ], + selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", + }; + }, + configure: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + }) => + await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }), + configureInteractive: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + configured, + }) => { + if (!configured) { + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: "update", + }); + } + const action = await prompter.select({ + message: "Matrix already configured. What do you want to do?", + options: [ + { value: "update", label: "Modify settings" }, + { value: "add-account", label: "Add account" }, + { value: "skip", label: "Skip (leave as-is)" }, + ], + initialValue: "update", + }); + if (action === "skip") { + return "skip"; + } + return await runMatrixConfigure({ + cfg: cfg as CoreConfig, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + intent: action === "add-account" ? "add-account" : "update", + }); + }, + afterConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => { + await runMatrixSetupBootstrapAfterConfigWrite({ + previousCfg: previousCfg as CoreConfig, + cfg: cfg as CoreConfig, + accountId, + runtime, + }); + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + matrix: { ...(cfg as CoreConfig).channels?.["matrix"], enabled: false }, + }, + }), +}; diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index 95c8cecee25..8f695efec3a 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -1,5 +1,5 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../runtime-api.js"; const mocks = vi.hoisted(() => ({ sendMessageMatrix: vi.fn(), @@ -75,6 +75,7 @@ describe("matrixOutbound cfg threading", () => { to: "room:!room:example", text: "caption", mediaUrl: "file:///tmp/cat.png", + mediaLocalRoots: ["/tmp/openclaw"], accountId: "default", }); @@ -84,6 +85,7 @@ describe("matrixOutbound cfg threading", () => { expect.objectContaining({ cfg, mediaUrl: "file:///tmp/cat.png", + mediaLocalRoots: ["/tmp/openclaw"], }), ); }); diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 9cdf8d412bf..c1f5dbc6d24 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -1,5 +1,4 @@ -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelOutboundAdapter } from "../runtime-api.js"; +import { resolveOutboundSendDep, type ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import { getMatrixRuntime } from "./runtime.js"; @@ -25,7 +24,17 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + deps, + replyToId, + threadId, + accountId, + }) => { const send = resolveOutboundSendDep(deps, "matrix") ?? sendMessageMatrix; const resolvedThreadId = @@ -33,6 +42,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { const result = await send(to, text, { cfg, mediaUrl, + mediaLocalRoots, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, accountId: accountId ?? undefined, diff --git a/extensions/matrix/src/plugin-entry.runtime.ts b/extensions/matrix/src/plugin-entry.runtime.ts new file mode 100644 index 00000000000..f5260242a72 --- /dev/null +++ b/extensions/matrix/src/plugin-entry.runtime.ts @@ -0,0 +1,67 @@ +import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/core"; +import { + bootstrapMatrixVerification, + getMatrixVerificationStatus, + verifyMatrixRecoveryKey, +} from "./matrix/actions/verification.js"; +import { ensureMatrixCryptoRuntime } from "./matrix/deps.js"; + +function sendError(respond: (ok: boolean, payload?: unknown) => void, err: unknown) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); +} + +export { ensureMatrixCryptoRuntime }; + +export async function handleVerifyRecoveryKey({ + params, + respond, +}: GatewayRequestHandlerOptions): Promise { + try { + const key = typeof params?.key === "string" ? params.key : ""; + if (!key.trim()) { + respond(false, { error: "key required" }); + return; + } + const accountId = + typeof params?.accountId === "string" ? params.accountId.trim() || undefined : undefined; + const result = await verifyMatrixRecoveryKey(key, { accountId }); + respond(result.success, result); + } catch (err) { + sendError(respond, err); + } +} + +export async function handleVerificationBootstrap({ + params, + respond, +}: GatewayRequestHandlerOptions): Promise { + try { + const accountId = + typeof params?.accountId === "string" ? params.accountId.trim() || undefined : undefined; + const recoveryKey = typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined; + const forceResetCrossSigning = params?.forceResetCrossSigning === true; + const result = await bootstrapMatrixVerification({ + accountId, + recoveryKey, + forceResetCrossSigning, + }); + respond(result.success, result); + } catch (err) { + sendError(respond, err); + } +} + +export async function handleVerificationStatus({ + params, + respond, +}: GatewayRequestHandlerOptions): Promise { + try { + const accountId = + typeof params?.accountId === "string" ? params.accountId.trim() || undefined : undefined; + const includeRecoveryKey = params?.includeRecoveryKey === true; + const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey }); + respond(true, status); + } catch (err) { + sendError(respond, err); + } +} diff --git a/extensions/matrix/src/profile-update.ts b/extensions/matrix/src/profile-update.ts new file mode 100644 index 00000000000..8de5726f8d9 --- /dev/null +++ b/extensions/matrix/src/profile-update.ts @@ -0,0 +1,68 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/matrix"; +import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; +import { updateMatrixAccountConfig, resolveMatrixConfigPath } from "./matrix/config-update.js"; +import { getMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +export type MatrixProfileUpdateResult = { + accountId: string; + displayName: string | null; + avatarUrl: string | null; + profile: { + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + uploadedAvatarSource: "http" | "path" | null; + convertedAvatarFromHttp: boolean; + }; + configPath: string; +}; + +export async function applyMatrixProfileUpdate(params: { + cfg?: CoreConfig; + account?: string; + displayName?: string; + avatarUrl?: string; + avatarPath?: string; + mediaLocalRoots?: readonly string[]; +}): Promise { + const runtime = getMatrixRuntime(); + const persistedCfg = runtime.config.loadConfig() as CoreConfig; + const accountId = normalizeAccountId(params.account); + const displayName = params.displayName?.trim() || null; + const avatarUrl = params.avatarUrl?.trim() || null; + const avatarPath = params.avatarPath?.trim() || null; + if (!displayName && !avatarUrl && !avatarPath) { + throw new Error("Provide name/displayName and/or avatarUrl/avatarPath."); + } + + const synced = await updateMatrixOwnProfile({ + cfg: params.cfg, + accountId, + displayName: displayName ?? undefined, + avatarUrl: avatarUrl ?? undefined, + avatarPath: avatarPath ?? undefined, + mediaLocalRoots: params.mediaLocalRoots, + }); + const persistedAvatarUrl = + synced.uploadedAvatarSource && synced.resolvedAvatarUrl ? synced.resolvedAvatarUrl : avatarUrl; + const updated = updateMatrixAccountConfig(persistedCfg, accountId, { + name: displayName ?? undefined, + avatarUrl: persistedAvatarUrl ?? undefined, + }); + await runtime.config.writeConfigFile(updated as never); + + return { + accountId, + displayName, + avatarUrl: persistedAvatarUrl ?? null, + profile: { + displayNameUpdated: synced.displayNameUpdated, + avatarUpdated: synced.avatarUpdated, + resolvedAvatarUrl: synced.resolvedAvatarUrl, + uploadedAvatarSource: synced.uploadedAvatarSource, + convertedAvatarFromHttp: synced.convertedAvatarFromHttp, + }, + configPath: resolveMatrixConfigPath(updated, accountId), + }; +} diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 7d47f09407e..801d61f71f5 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -1,5 +1,5 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi, beforeEach } from "vitest"; -import type { ChannelDirectoryEntry } from "../runtime-api.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; @@ -33,6 +33,12 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.resolved).toBe(true); expect(result?.id).toBe("@alice:example.org"); + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: undefined, + query: "Alice", + limit: 5, + }); }); it("does not resolve ambiguous or non-exact matches", async () => { @@ -63,6 +69,102 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.resolved).toBe(true); expect(result?.id).toBe("!two:example.org"); - expect(result?.note).toBe("multiple matches; chose first"); + expect(result?.note).toBeUndefined(); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: undefined, + query: "#team", + limit: 5, + }); + }); + + it("threads accountId into live Matrix target lookups", async () => { + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + ]); + vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([ + { kind: "group", id: "!team:example.org", name: "Team", handle: "#team" }, + ]); + + await resolveMatrixTargets({ + cfg: {}, + accountId: "ops", + inputs: ["Alice"], + kind: "user", + }); + await resolveMatrixTargets({ + cfg: {}, + accountId: "ops", + inputs: ["#team"], + kind: "group", + }); + + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: "ops", + query: "Alice", + limit: 5, + }); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledWith({ + cfg: {}, + accountId: "ops", + query: "#team", + limit: 5, + }); + }); + + it("reuses directory lookups for normalized duplicate inputs", async () => { + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + ]); + vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([ + { kind: "group", id: "!team:example.org", name: "Team", handle: "#team" }, + ]); + + const userResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["Alice", " alice "], + kind: "user", + }); + const groupResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["#team", "#team"], + kind: "group", + }); + + expect(userResults.every((entry) => entry.resolved)).toBe(true); + expect(groupResults.every((entry) => entry.resolved)).toBe(true); + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); + }); + + it("accepts prefixed fully qualified ids without directory lookups", async () => { + const userResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["matrix:user:@alice:example.org"], + kind: "user", + }); + const groupResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["matrix:room:!team:example.org"], + kind: "group", + }); + + expect(userResults).toEqual([ + { + input: "matrix:user:@alice:example.org", + resolved: true, + id: "@alice:example.org", + }, + ]); + expect(groupResults).toEqual([ + { + input: "matrix:room:!team:example.org", + resolved: true, + id: "!team:example.org", + }, + ]); + expect(listMatrixDirectoryPeersLive).not.toHaveBeenCalled(); + expect(listMatrixDirectoryGroupsLive).not.toHaveBeenCalled(); }); }); diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index 2589595ba12..471d9e7f33a 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -1,17 +1,21 @@ -import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allowlist-resolution"; import type { ChannelDirectoryEntry, ChannelResolveKind, ChannelResolveResult, RuntimeEnv, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/matrix"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { isMatrixQualifiedUserId, normalizeMatrixMessagingTarget } from "./matrix/target-ids.js"; + +function normalizeLookupQuery(query: string): string { + return query.trim().toLowerCase(); +} function findExactDirectoryMatches( matches: ChannelDirectoryEntry[], query: string, ): ChannelDirectoryEntry[] { - const normalized = query.trim().toLowerCase(); + const normalized = normalizeLookupQuery(query); if (!normalized) { return []; } @@ -26,12 +30,21 @@ function findExactDirectoryMatches( function pickBestGroupMatch( matches: ChannelDirectoryEntry[], query: string, -): ChannelDirectoryEntry | undefined { +): { best?: ChannelDirectoryEntry; note?: string } { if (matches.length === 0) { - return undefined; + return {}; } - const [exact] = findExactDirectoryMatches(matches, query); - return exact ?? matches[0]; + const exact = findExactDirectoryMatches(matches, query); + if (exact.length > 1) { + return { best: exact[0], note: "multiple exact matches; chose first" }; + } + if (exact.length === 1) { + return { best: exact[0] }; + } + return { + best: matches[0], + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }; } function pickBestUserMatch( @@ -52,7 +65,7 @@ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: strin if (matches.length === 0) { return "no matches"; } - const normalized = query.trim().toLowerCase(); + const normalized = normalizeLookupQuery(query); if (!normalized) { return "empty input"; } @@ -66,60 +79,96 @@ function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: strin return "no exact match; use full Matrix ID"; } +async function readCachedMatches( + cache: Map, + query: string, + lookup: (query: string) => Promise, +): Promise { + const key = normalizeLookupQuery(query); + if (!key) { + return []; + } + const cached = cache.get(key); + if (cached) { + return cached; + } + const matches = await lookup(query.trim()); + cache.set(key, matches); + return matches; +} + export async function resolveMatrixTargets(params: { cfg: unknown; + accountId?: string | null; inputs: string[]; kind: ChannelResolveKind; runtime?: RuntimeEnv; }): Promise { - return await mapAllowlistResolutionInputs({ - inputs: params.inputs, - mapInput: async (input): Promise => { - const trimmed = input.trim(); - if (!trimmed) { - return { input, resolved: false, note: "empty input" }; - } - if (params.kind === "user") { - if (trimmed.startsWith("@") && trimmed.includes(":")) { - return { input, resolved: true, id: trimmed }; - } - try { - const matches = await listMatrixDirectoryPeersLive({ - cfg: params.cfg, - query: trimmed, - limit: 5, - }); - const best = pickBestUserMatch(matches, trimmed); - return { - input, - resolved: Boolean(best?.id), - id: best?.id, - name: best?.name, - note: best ? undefined : describeUserMatchFailure(matches, trimmed), - }; - } catch (err) { - params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); - return { input, resolved: false, note: "lookup failed" }; - } + const results: ChannelResolveResult[] = []; + const userLookupCache = new Map(); + const groupLookupCache = new Map(); + + for (const input of params.inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (params.kind === "user") { + const normalizedTarget = normalizeMatrixMessagingTarget(trimmed); + if (normalizedTarget && isMatrixQualifiedUserId(normalizedTarget)) { + results.push({ input, resolved: true, id: normalizedTarget }); + continue; } try { - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query: trimmed, - limit: 5, - }); - const best = pickBestGroupMatch(matches, trimmed); - return { + const matches = await readCachedMatches(userLookupCache, trimmed, (query) => + listMatrixDirectoryPeersLive({ + cfg: params.cfg, + accountId: params.accountId, + query, + limit: 5, + }), + ); + const best = pickBestUserMatch(matches, trimmed); + results.push({ input, resolved: Boolean(best?.id), id: best?.id, name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, - }; + note: best ? undefined : describeUserMatchFailure(matches, trimmed), + }); } catch (err) { params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); - return { input, resolved: false, note: "lookup failed" }; + results.push({ input, resolved: false, note: "lookup failed" }); } - }, - }); + continue; + } + const normalizedTarget = normalizeMatrixMessagingTarget(trimmed); + if (normalizedTarget?.startsWith("!")) { + results.push({ input, resolved: true, id: normalizedTarget }); + continue; + } + try { + const matches = await readCachedMatches(groupLookupCache, trimmed, (query) => + listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + accountId: params.accountId, + query, + limit: 5, + }), + ); + const { best, note } = pickBestGroupMatch(matches, trimmed); + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; } diff --git a/extensions/matrix/src/runtime-api.test.ts b/extensions/matrix/src/runtime-api.test.ts deleted file mode 100644 index 680143f429c..00000000000 --- a/extensions/matrix/src/runtime-api.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import * as runtimeApi from "../runtime-api.js"; - -describe("matrix runtime-api", () => { - it("re-exports createAccountListHelpers as a live runtime value", () => { - expect(typeof runtimeApi.createAccountListHelpers).toBe("function"); - - const helpers = runtimeApi.createAccountListHelpers("matrix"); - expect(typeof helpers.listAccountIds).toBe("function"); - expect(typeof helpers.resolveDefaultAccountId).toBe("function"); - }); - - it("re-exports buildSecretInputSchema for config schema helpers", () => { - expect(typeof runtimeApi.buildSecretInputSchema).toBe("function"); - }); - - it("re-exports setup entrypoints from the bundled plugin-sdk surface", () => { - expect(typeof runtimeApi.matrixSetupWizard).toBe("object"); - expect(typeof runtimeApi.matrixSetupAdapter).toBe("object"); - }); -}); diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts new file mode 100644 index 00000000000..ece735819df --- /dev/null +++ b/extensions/matrix/src/runtime-api.ts @@ -0,0 +1 @@ +export * from "../runtime-api.js"; diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index 8738611fde6..42324df7e7c 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,10 +1,7 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; -import type { PluginRuntime } from "../runtime-api.js"; -const { - setRuntime: setMatrixRuntime, - clearRuntime: clearMatrixRuntime, - tryGetRuntime: tryGetMatrixRuntime, - getRuntime: getMatrixRuntime, -} = createPluginRuntimeStore("Matrix runtime not initialized"); -export { clearMatrixRuntime, getMatrixRuntime, setMatrixRuntime, tryGetMatrixRuntime }; +const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = + createPluginRuntimeStore("Matrix runtime not initialized"); + +export { getMatrixRuntime, setMatrixRuntime }; diff --git a/extensions/matrix/src/secret-input.ts b/extensions/matrix/src/secret-input.ts deleted file mode 100644 index f1b2aae5c92..00000000000 --- a/extensions/matrix/src/secret-input.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - buildSecretInputSchema, - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/matrix/src/setup-bootstrap.ts b/extensions/matrix/src/setup-bootstrap.ts new file mode 100644 index 00000000000..6c1304de498 --- /dev/null +++ b/extensions/matrix/src/setup-bootstrap.ts @@ -0,0 +1,93 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix"; +import { hasExplicitMatrixAccountConfig } from "./matrix/account-config.js"; +import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; +import { bootstrapMatrixVerification } from "./matrix/actions/verification.js"; +import type { CoreConfig } from "./types.js"; + +export type MatrixSetupVerificationBootstrapResult = { + attempted: boolean; + success: boolean; + recoveryKeyCreatedAt: string | null; + backupVersion: string | null; + error?: string; +}; + +export async function maybeBootstrapNewEncryptedMatrixAccount(params: { + previousCfg: CoreConfig; + cfg: CoreConfig; + accountId: string; +}): Promise { + const accountConfig = resolveMatrixAccountConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + + if ( + hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) || + accountConfig.encryption !== true + ) { + return { + attempted: false, + success: false, + recoveryKeyCreatedAt: null, + backupVersion: null, + }; + } + + try { + const bootstrap = await bootstrapMatrixVerification({ accountId: params.accountId }); + return { + attempted: true, + success: bootstrap.success === true, + recoveryKeyCreatedAt: bootstrap.verification.recoveryKeyCreatedAt, + backupVersion: bootstrap.verification.backupVersion, + ...(bootstrap.success + ? {} + : { error: bootstrap.error ?? "Matrix verification bootstrap failed" }), + }; + } catch (err) { + return { + attempted: true, + success: false, + recoveryKeyCreatedAt: null, + backupVersion: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function runMatrixSetupBootstrapAfterConfigWrite(params: { + previousCfg: CoreConfig; + cfg: CoreConfig; + accountId: string; + runtime: RuntimeEnv; +}): Promise { + const nextAccountConfig = resolveMatrixAccountConfig({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (nextAccountConfig.encryption !== true) { + return; + } + + const bootstrap = await maybeBootstrapNewEncryptedMatrixAccount({ + previousCfg: params.previousCfg, + cfg: params.cfg, + accountId: params.accountId, + }); + if (!bootstrap.attempted) { + return; + } + if (bootstrap.success) { + params.runtime.log(`Matrix verification bootstrap: complete for "${params.accountId}".`); + if (bootstrap.backupVersion) { + params.runtime.log( + `Matrix backup version for "${params.accountId}": ${bootstrap.backupVersion}`, + ); + } + return; + } + params.runtime.error( + `Matrix verification bootstrap warning for "${params.accountId}": ${bootstrap.error ?? "unknown bootstrap failure"}`, + ); +} diff --git a/extensions/matrix/src/setup-config.ts b/extensions/matrix/src/setup-config.ts new file mode 100644 index 00000000000..f04b11ac7b3 --- /dev/null +++ b/extensions/matrix/src/setup-config.ts @@ -0,0 +1,89 @@ +import { + applyAccountNameToChannelSection, + DEFAULT_ACCOUNT_ID, + moveSingleAccountChannelSectionToDefaultAccount, + normalizeAccountId, + normalizeSecretInputString, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/matrix"; +import { resolveMatrixEnvAuthReadiness } from "./matrix/client.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +export function validateMatrixSetupInput(params: { + accountId: string; + input: ChannelSetupInput; +}): string | null { + if (params.input.useEnv) { + const envReadiness = resolveMatrixEnvAuthReadiness(params.accountId, process.env); + return envReadiness.ready ? null : envReadiness.missingMessage; + } + if (!params.input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = params.input.accessToken?.trim(); + const password = normalizeSecretInputString(params.input.password); + const userId = params.input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; +} + +export function applyMatrixSetupAccountConfig(params: { + cfg: CoreConfig; + accountId: string; + input: ChannelSetupInput; + avatarUrl?: string; +}): CoreConfig { + const normalizedAccountId = normalizeAccountId(params.accountId); + const migratedCfg = + normalizedAccountId !== DEFAULT_ACCOUNT_ID + ? (moveSingleAccountChannelSectionToDefaultAccount({ + cfg: params.cfg, + channelKey: channel, + }) as CoreConfig) + : params.cfg; + const next = applyAccountNameToChannelSection({ + cfg: migratedCfg, + channelKey: channel, + accountId: normalizedAccountId, + name: params.input.name, + }) as CoreConfig; + + if (params.input.useEnv) { + return updateMatrixAccountConfig(next, normalizedAccountId, { + enabled: true, + homeserver: null, + userId: null, + accessToken: null, + password: null, + deviceId: null, + deviceName: null, + }); + } + + const accessToken = params.input.accessToken?.trim(); + const password = normalizeSecretInputString(params.input.password); + const userId = params.input.userId?.trim(); + return updateMatrixAccountConfig(next, normalizedAccountId, { + enabled: true, + homeserver: params.input.homeserver?.trim(), + userId: password && !userId ? null : userId, + accessToken: accessToken || (password ? null : undefined), + password: password || (accessToken ? null : undefined), + deviceName: params.input.deviceName?.trim(), + avatarUrl: params.avatarUrl, + initialSyncLimit: params.input.initialSyncLimit, + }); +} diff --git a/extensions/matrix/src/setup-core.test.ts b/extensions/matrix/src/setup-core.test.ts new file mode 100644 index 00000000000..01159d276f7 --- /dev/null +++ b/extensions/matrix/src/setup-core.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { matrixSetupAdapter } from "./setup-core.js"; +import type { CoreConfig } from "./types.js"; + +describe("matrixSetupAdapter", () => { + it("moves legacy default config before writing a named account", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@default:example.org", + accessToken: "default-token", + deviceName: "Default device", + }, + }, + } as CoreConfig; + + const next = matrixSetupAdapter.applyAccountConfig({ + cfg, + accountId: "ops", + input: { + name: "Ops", + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + }, + }) as CoreConfig; + + expect(next.channels?.matrix?.homeserver).toBeUndefined(); + expect(next.channels?.matrix?.userId).toBeUndefined(); + expect(next.channels?.matrix?.accessToken).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.default).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@default:example.org", + accessToken: "default-token", + deviceName: "Default device", + }); + expect(next.channels?.matrix?.accounts?.ops).toMatchObject({ + name: "Ops", + enabled: true, + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + }); + }); + + it("clears stored auth fields when switching an account to env-backed auth", () => { + const cfg = { + channels: { + matrix: { + accounts: { + ops: { + name: "Ops", + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "ops-token", + password: "secret", + deviceId: "DEVICE", + deviceName: "Ops device", + }, + }, + }, + }, + } as CoreConfig; + + const next = matrixSetupAdapter.applyAccountConfig({ + cfg, + accountId: "ops", + input: { + name: "Ops", + useEnv: true, + }, + }) as CoreConfig; + + expect(next.channels?.matrix?.accounts?.ops).toMatchObject({ + name: "Ops", + enabled: true, + }); + expect(next.channels?.matrix?.accounts?.ops?.homeserver).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.userId).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.accessToken).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.password).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.deviceId).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.ops?.deviceName).toBeUndefined(); + }); +}); diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index 5e5973bd05e..298a29d8d0a 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,13 +1,20 @@ import { + DEFAULT_ACCOUNT_ID, normalizeAccountId, - normalizeSecretInputString, prepareScopedSetupConfig, type ChannelSetupAdapter, } from "openclaw/plugin-sdk/setup"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { runMatrixSetupBootstrapAfterConfigWrite } from "./setup-bootstrap.js"; +import { applyMatrixSetupAccountConfig, validateMatrixSetupInput } from "./setup-config.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +function resolveMatrixSetupAccountId(params: { accountId?: string; name?: string }): string { + return normalizeAccountId(params.accountId?.trim() || params.name?.trim() || DEFAULT_ACCOUNT_ID); +} + export function buildMatrixConfigUpdate( cfg: CoreConfig, input: { @@ -19,29 +26,28 @@ export function buildMatrixConfigUpdate( initialSyncLimit?: number; }, ): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; + return updateMatrixAccountConfig(cfg, DEFAULT_ACCOUNT_ID, { + enabled: true, + homeserver: input.homeserver, + userId: input.userId, + accessToken: input.accessToken, + password: input.password, + deviceName: input.deviceName, + initialSyncLimit: input.initialSyncLimit, + }); } export const matrixSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + resolveAccountId: ({ accountId, input }) => + resolveMatrixSetupAccountId({ + accountId, + name: input?.name, + }), + resolveBindingAccountId: ({ accountId, agentId }) => + resolveMatrixSetupAccountId({ + accountId, + name: agentId, + }), applyAccountName: ({ cfg, accountId, name }) => prepareScopedSetupConfig({ cfg: cfg as CoreConfig, @@ -49,56 +55,19 @@ export const matrixSetupAdapter: ChannelSetupAdapter = { accountId, name, }) as CoreConfig, - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const next = prepareScopedSetupConfig({ + validateInput: ({ accountId, input }) => validateMatrixSetupInput({ accountId, input }), + applyAccountConfig: ({ cfg, accountId, input }) => + applyMatrixSetupAccountConfig({ cfg: cfg as CoreConfig, - channelKey: channel, accountId, - name: input.name, - migrateBaseName: true, - }) as CoreConfig; - if (input.useEnv) { - return { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(next as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, + input, + }), + afterAccountConfigWritten: async ({ previousCfg, cfg, accountId, runtime }) => { + await runMatrixSetupBootstrapAfterConfigWrite({ + previousCfg: previousCfg as CoreConfig, + cfg: cfg as CoreConfig, + accountId, + runtime, }); }, }; diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index bf2a3769d96..ed601b90400 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,443 +1 @@ -import { - buildSingleChannelSecretPromptState, - createNestedChannelDmPolicy, - createTopLevelChannelGroupPolicySetter, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - formatResolvedUnresolvedNote, - hasConfiguredSecretInput, - mergeAllowFromEntries, - patchNestedChannelConfigSection, - promptSingleChannelSecretInput, - type ChannelSetupDmPolicy, - type ChannelSetupWizard, - type OpenClawConfig, - type SecretInput, - type WizardPrompter, -} from "openclaw/plugin-sdk/setup"; -import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; -import { resolveMatrixAccount } from "./matrix/accounts.js"; -import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; -import { buildMatrixConfigUpdate, matrixSetupAdapter } from "./setup-core.js"; -import type { CoreConfig } from "./types.js"; - -const channel = "matrix" as const; -const setMatrixGroupPolicy = createTopLevelChannelGroupPolicySetter({ - channel, - enabled: true, -}); - -async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "Matrix requires a homeserver URL.", - "Use an access token (recommended) or a password (logs in and stores a token).", - "With access token: user ID is fetched automatically.", - "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", - `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, - ].join("\n"), - "Matrix setup", - ); -} - -async function promptMatrixAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; -}): Promise { - const { cfg, prompter } = params; - const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; - const account = resolveMatrixAccount({ cfg }); - const canResolve = Boolean(account.configured); - - const parseInput = (raw: string) => - raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); - - const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); - - while (true) { - const entry = await prompter.text({ - message: "Matrix allowFrom (full @user:server; display name only if unique)", - placeholder: "@user:server", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = parseInput(String(entry)); - const resolvedIds: string[] = []; - const pending: string[] = []; - const unresolved: string[] = []; - const unresolvedNotes: string[] = []; - - for (const part of parts) { - if (isFullUserId(part)) { - resolvedIds.push(part); - continue; - } - if (!canResolve) { - unresolved.push(part); - continue; - } - pending.push(part); - } - - if (pending.length > 0) { - const results = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - }).catch(() => []); - for (const result of results) { - if (result?.resolved && result.id) { - resolvedIds.push(result.id); - continue; - } - if (result?.input) { - unresolved.push(result.input); - if (result.note) { - unresolvedNotes.push(`${result.input}: ${result.note}`); - } - } - } - } - - if (unresolved.length > 0) { - const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; - await prompter.note( - `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, - "Matrix allowlist", - ); - continue; - } - - const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); - return patchNestedChannelConfigSection({ - cfg, - channel, - section: "dm", - enabled: true, - patch: { - policy: "allowlist", - allowFrom: unique, - }, - }) as CoreConfig; - } -} - -function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { - const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...cfg.channels?.matrix, - enabled: true, - groups, - }, - }, - }; -} - -async function resolveMatrixGroupRooms(params: { - cfg: CoreConfig; - entries: string[]; - prompter: Pick; -}): Promise { - if (params.entries.length === 0) { - return []; - } - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of params.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - const roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await params.prompter.note(resolution, "Matrix rooms"); - } - return roomKeys; - } catch (err) { - await params.prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - return params.entries.map((entry) => entry.trim()).filter(Boolean); - } -} - -const matrixGroupAccess: NonNullable = { - label: "Matrix rooms", - placeholder: "!roomId:server, #alias:server, Project Room", - currentPolicy: ({ cfg }) => cfg.channels?.matrix?.groupPolicy ?? "allowlist", - currentEntries: ({ cfg }) => - Object.keys(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms ?? {}), - updatePrompt: ({ cfg }) => Boolean(cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms), - setPolicy: ({ cfg, policy }) => setMatrixGroupPolicy(cfg as CoreConfig, policy), - resolveAllowlist: async ({ cfg, entries, prompter }) => - await resolveMatrixGroupRooms({ - cfg: cfg as CoreConfig, - entries, - prompter, - }), - applyAllowlist: ({ cfg, resolved }) => - setMatrixGroupRooms(cfg as CoreConfig, resolved as string[]), -}; - -const matrixDmPolicy: ChannelSetupDmPolicy = createNestedChannelDmPolicy({ - label: "Matrix", - channel, - section: "dm", - policyKey: "channels.matrix.dm.policy", - allowFromKey: "channels.matrix.dm.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", - promptAllowFrom: promptMatrixAllowFrom, - enabled: true, -}); - -export { matrixSetupAdapter } from "./setup-core.js"; - -export const matrixSetupWizard: ChannelSetupWizard = { - channel, - resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, - resolveShouldPromptAccountIds: () => false, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs homeserver + access token or password", - configuredHint: "configured", - unconfiguredHint: "needs auth", - resolveConfigured: ({ cfg }) => resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured, - resolveStatusLines: ({ cfg }) => { - const configured = resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured; - return [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ]; - }, - resolveSelectionHint: ({ cfg, configured }) => { - if (!isMatrixSdkAvailable()) { - return "install @vector-im/matrix-bot-sdk"; - } - return configured ? "configured" : "needs auth"; - }, - }, - credentials: [], - finalize: async ({ cfg, runtime, prompter, forceAllowFrom }) => { - let next = cfg as CoreConfig; - await ensureMatrixSdkInstalled({ - runtime, - confirm: async (message) => - await prompter.confirm({ - message, - initialValue: true, - }), - }); - const existing = next.channels?.matrix ?? {}; - const account = resolveMatrixAccount({ cfg: next }); - if (!account.configured) { - await noteMatrixAuthHelp(prompter); - } - - const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); - const envUserId = process.env.MATRIX_USER_ID?.trim(); - const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); - const envPassword = process.env.MATRIX_PASSWORD?.trim(); - const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); - - if ( - envReady && - !existing.homeserver && - !existing.userId && - !existing.accessToken && - !existing.password - ) { - const useEnv = await prompter.confirm({ - message: "Matrix env vars detected. Use env values?", - initialValue: true, - }); - if (useEnv) { - next = matrixSetupAdapter.applyAccountConfig({ - cfg: next, - accountId: DEFAULT_ACCOUNT_ID, - input: { useEnv: true }, - }) as CoreConfig; - if (forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter }); - } - return { cfg: next }; - } - } - - const homeserver = String( - await prompter.text({ - message: "Matrix homeserver URL", - initialValue: existing.homeserver ?? envHomeserver, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!/^https?:\/\//i.test(raw)) { - return "Use a full URL (https://...)"; - } - return undefined; - }, - }), - ).trim(); - - let accessToken = existing.accessToken ?? ""; - let password: SecretInput | undefined = existing.password; - let userId = existing.userId ?? ""; - const existingPasswordConfigured = hasConfiguredSecretInput(existing.password); - const passwordConfigured = () => hasConfiguredSecretInput(password); - - if (accessToken || passwordConfigured()) { - const keep = await prompter.confirm({ - message: "Matrix credentials already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - accessToken = ""; - password = undefined; - userId = ""; - } - } - - if (!accessToken && !passwordConfigured()) { - const authMode = await prompter.select({ - message: "Matrix auth method", - options: [ - { value: "token", label: "Access token (user ID fetched automatically)" }, - { value: "password", label: "Password (requires user ID)" }, - ], - }); - - if (authMode === "token") { - accessToken = String( - await prompter.text({ - message: "Matrix access token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - userId = ""; - } else { - userId = String( - await prompter.text({ - message: "Matrix user ID", - initialValue: existing.userId ?? envUserId, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!raw.startsWith("@")) { - return "Matrix user IDs should start with @"; - } - if (!raw.includes(":")) { - return "Matrix user IDs should include a server (:server)"; - } - return undefined; - }, - }), - ).trim(); - const passwordPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: Boolean(existingPasswordConfigured), - hasConfigToken: existingPasswordConfigured, - allowEnv: true, - envValue: envPassword, - }); - const passwordResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: channel, - credentialLabel: "password", - accountConfigured: passwordPromptState.accountConfigured, - canUseEnv: passwordPromptState.canUseEnv, - hasConfigToken: passwordPromptState.hasConfigToken, - envPrompt: "MATRIX_PASSWORD detected. Use env var?", - keepPrompt: "Matrix password already configured. Keep it?", - inputPrompt: "Matrix password", - preferredEnvVar: "MATRIX_PASSWORD", - }); - if (passwordResult.action === "set") { - password = passwordResult.value; - } - if (passwordResult.action === "use-env") { - password = undefined; - } - } - } - - const deviceName = String( - await prompter.text({ - message: "Matrix device name (optional)", - initialValue: existing.deviceName ?? "OpenClaw Gateway", - }), - ).trim(); - - const enableEncryption = await prompter.confirm({ - message: "Enable end-to-end encryption (E2EE)?", - initialValue: existing.encryption ?? false, - }); - - next = { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - homeserver, - userId: userId || undefined, - accessToken: accessToken || undefined, - password, - deviceName: deviceName || undefined, - encryption: enableEncryption || undefined, - }, - }, - }; - - if (forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter }); - } - - return { cfg: next }; - }, - dmPolicy: matrixDmPolicy, - groupAccess: matrixGroupAccess, - disable: (cfg) => ({ - ...(cfg as CoreConfig), - channels: { - ...(cfg as CoreConfig).channels, - matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false }, - }, - }), -}; +export { matrixOnboardingAdapter } from "./onboarding.js"; diff --git a/extensions/matrix/src/storage-paths.ts b/extensions/matrix/src/storage-paths.ts new file mode 100644 index 00000000000..5e1a3d394c3 --- /dev/null +++ b/extensions/matrix/src/storage-paths.ts @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; + +export function sanitizeMatrixPathSegment(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return cleaned || "unknown"; +} + +export function resolveMatrixHomeserverKey(homeserver: string): string { + try { + const url = new URL(homeserver); + if (url.host) { + return sanitizeMatrixPathSegment(url.host); + } + } catch { + // fall through + } + return sanitizeMatrixPathSegment(homeserver); +} + +export function hashMatrixAccessToken(accessToken: string): string { + return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); +} + +export function resolveMatrixCredentialsFilename(accountId?: string | null): string { + const normalized = normalizeAccountId(accountId); + return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`; +} + +export function resolveMatrixCredentialsDir(stateDir: string): string { + return path.join(stateDir, "credentials", "matrix"); +} + +export function resolveMatrixCredentialsPath(params: { + stateDir: string; + accountId?: string | null; +}): string { + return path.join( + resolveMatrixCredentialsDir(params.stateDir), + resolveMatrixCredentialsFilename(params.accountId), + ); +} + +export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string { + return path.join(stateDir, "matrix"); +} + +export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): { + rootDir: string; + storagePath: string; + cryptoPath: string; +} { + const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir); + return { + rootDir, + storagePath: path.join(rootDir, "bot-storage.json"), + cryptoPath: path.join(rootDir, "crypto"), + }; +} + +export function resolveMatrixAccountStorageRoot(params: { + stateDir: string; + homeserver: string; + userId: string; + accessToken: string; + accountId?: string | null; +}): { + rootDir: string; + accountKey: string; + tokenHash: string; +} { + const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID); + const userKey = sanitizeMatrixPathSegment(params.userId); + const serverKey = resolveMatrixHomeserverKey(params.homeserver); + const tokenHash = hashMatrixAccessToken(params.accessToken); + return { + rootDir: path.join( + params.stateDir, + "matrix", + "accounts", + accountKey, + `${serverKey}__${userKey}`, + tokenHash, + ), + accountKey, + tokenHash, + }; +} diff --git a/extensions/matrix/src/tool-actions.runtime.ts b/extensions/matrix/src/tool-actions.runtime.ts new file mode 100644 index 00000000000..d93f397207f --- /dev/null +++ b/extensions/matrix/src/tool-actions.runtime.ts @@ -0,0 +1 @@ +export { handleMatrixAction } from "./tool-actions.js"; diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts new file mode 100644 index 00000000000..d917f33090f --- /dev/null +++ b/extensions/matrix/src/tool-actions.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleMatrixAction } from "./tool-actions.js"; +import type { CoreConfig } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + voteMatrixPoll: vi.fn(), + reactMatrixMessage: vi.fn(), + listMatrixReactions: vi.fn(), + removeMatrixReactions: vi.fn(), + sendMatrixMessage: vi.fn(), + listMatrixPins: vi.fn(), + getMatrixMemberInfo: vi.fn(), + getMatrixRoomInfo: vi.fn(), + applyMatrixProfileUpdate: vi.fn(), +})); + +vi.mock("./matrix/actions.js", async () => { + const actual = await vi.importActual("./matrix/actions.js"); + return { + ...actual, + getMatrixMemberInfo: mocks.getMatrixMemberInfo, + getMatrixRoomInfo: mocks.getMatrixRoomInfo, + listMatrixReactions: mocks.listMatrixReactions, + listMatrixPins: mocks.listMatrixPins, + removeMatrixReactions: mocks.removeMatrixReactions, + sendMatrixMessage: mocks.sendMatrixMessage, + voteMatrixPoll: mocks.voteMatrixPoll, + }; +}); + +vi.mock("./matrix/send.js", async () => { + const actual = await vi.importActual("./matrix/send.js"); + return { + ...actual, + reactMatrixMessage: mocks.reactMatrixMessage, + }; +}); + +vi.mock("./profile-update.js", () => ({ + applyMatrixProfileUpdate: (...args: unknown[]) => mocks.applyMatrixProfileUpdate(...args), +})); + +describe("handleMatrixAction pollVote", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.voteMatrixPoll.mockResolvedValue({ + eventId: "evt-poll-vote", + roomId: "!room:example", + pollId: "$poll", + answerIds: ["a1", "a2"], + labels: ["Pizza", "Sushi"], + maxSelections: 2, + }); + mocks.listMatrixReactions.mockResolvedValue([{ key: "👍", count: 1, users: ["@u:example"] }]); + mocks.listMatrixPins.mockResolvedValue({ pinned: ["$pin"], events: [] }); + mocks.removeMatrixReactions.mockResolvedValue({ removed: 1 }); + mocks.sendMatrixMessage.mockResolvedValue({ + messageId: "$sent", + roomId: "!room:example", + }); + mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" }); + mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" }); + mocks.applyMatrixProfileUpdate.mockResolvedValue({ + accountId: "ops", + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + profile: { + displayNameUpdated: true, + avatarUpdated: true, + resolvedAvatarUrl: "mxc://example/avatar", + uploadedAvatarSource: null, + convertedAvatarFromHttp: false, + }, + configPath: "channels.matrix.accounts.ops", + }); + }); + + it("parses snake_case vote params and forwards normalized selectors", async () => { + const cfg = {} as CoreConfig; + const result = await handleMatrixAction( + { + action: "pollVote", + account_id: "main", + room_id: "!room:example", + poll_id: "$poll", + poll_option_id: "a1", + poll_option_ids: ["a2", ""], + poll_option_index: "2", + poll_option_indexes: ["1", "bogus"], + }, + cfg, + ); + + expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", { + cfg, + accountId: "main", + optionIds: ["a2", "a1"], + optionIndexes: [1, 2], + }); + expect(result.details).toMatchObject({ + ok: true, + result: { + eventId: "evt-poll-vote", + answerIds: ["a1", "a2"], + }, + }); + }); + + it("rejects missing poll ids", async () => { + await expect( + handleMatrixAction( + { + action: "pollVote", + roomId: "!room:example", + pollOptionIndex: 1, + }, + {} as CoreConfig, + ), + ).rejects.toThrow("pollId required"); + }); + + it("passes account-scoped opts to add reactions", async () => { + const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "react", + accountId: "ops", + roomId: "!room:example", + messageId: "$msg", + emoji: "👍", + }, + cfg, + ); + + expect(mocks.reactMatrixMessage).toHaveBeenCalledWith("!room:example", "$msg", "👍", { + cfg, + accountId: "ops", + }); + }); + + it("passes account-scoped opts to remove reactions", async () => { + const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "react", + account_id: "ops", + room_id: "!room:example", + message_id: "$msg", + emoji: "👍", + remove: true, + }, + cfg, + ); + + expect(mocks.removeMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { + cfg, + accountId: "ops", + emoji: "👍", + }); + }); + + it("passes account-scoped opts and limit to reaction listing", async () => { + const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; + const result = await handleMatrixAction( + { + action: "reactions", + account_id: "ops", + room_id: "!room:example", + message_id: "$msg", + limit: "5", + }, + cfg, + ); + + expect(mocks.listMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { + cfg, + accountId: "ops", + limit: 5, + }); + expect(result.details).toMatchObject({ + ok: true, + reactions: [{ key: "👍", count: 1 }], + }); + }); + + it("passes account-scoped opts to message sends", async () => { + const cfg = { channels: { matrix: { actions: { messages: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "sendMessage", + accountId: "ops", + to: "room:!room:example", + content: "hello", + threadId: "$thread", + }, + cfg, + { mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }, + ); + + expect(mocks.sendMatrixMessage).toHaveBeenCalledWith("room:!room:example", "hello", { + cfg, + accountId: "ops", + mediaUrl: undefined, + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + replyToId: undefined, + threadId: "$thread", + }); + }); + + it("accepts media-only message sends", async () => { + const cfg = { channels: { matrix: { actions: { messages: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "sendMessage", + accountId: "ops", + to: "room:!room:example", + mediaUrl: "file:///tmp/photo.png", + }, + cfg, + { mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }, + ); + + expect(mocks.sendMatrixMessage).toHaveBeenCalledWith("room:!room:example", undefined, { + cfg, + accountId: "ops", + mediaUrl: "file:///tmp/photo.png", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + replyToId: undefined, + threadId: undefined, + }); + }); + + it("passes mediaLocalRoots to profile updates", async () => { + const cfg = { channels: { matrix: { actions: { profile: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "setProfile", + accountId: "ops", + avatarPath: "/tmp/avatar.jpg", + }, + cfg, + { mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }, + ); + + expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + cfg, + account: "ops", + avatarPath: "/tmp/avatar.jpg", + mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + }), + ); + }); + + it("passes account-scoped opts to pin listing", async () => { + const cfg = { channels: { matrix: { actions: { pins: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "listPins", + accountId: "ops", + roomId: "!room:example", + }, + cfg, + ); + + expect(mocks.listMatrixPins).toHaveBeenCalledWith("!room:example", { + cfg, + accountId: "ops", + }); + }); + + it("passes account-scoped opts to member and room info actions", async () => { + const memberCfg = { + channels: { matrix: { actions: { memberInfo: true } } }, + } as CoreConfig; + await handleMatrixAction( + { + action: "memberInfo", + accountId: "ops", + userId: "@u:example", + roomId: "!room:example", + }, + memberCfg, + ); + const roomCfg = { channels: { matrix: { actions: { channelInfo: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "channelInfo", + accountId: "ops", + roomId: "!room:example", + }, + roomCfg, + ); + + expect(mocks.getMatrixMemberInfo).toHaveBeenCalledWith("@u:example", { + cfg: memberCfg, + accountId: "ops", + roomId: "!room:example", + }); + expect(mocks.getMatrixRoomInfo).toHaveBeenCalledWith("!room:example", { + cfg: roomCfg, + accountId: "ops", + }); + }); + + it("persists self-profile updates through the shared profile helper", async () => { + const cfg = { channels: { matrix: { actions: { profile: true } } } } as CoreConfig; + const result = await handleMatrixAction( + { + action: "setProfile", + account_id: "ops", + display_name: "Ops Bot", + avatar_url: "mxc://example/avatar", + }, + cfg, + ); + + expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({ + cfg, + account: "ops", + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + }); + expect(result.details).toMatchObject({ + ok: true, + accountId: "ops", + profile: { + displayNameUpdated: true, + avatarUpdated: true, + }, + }); + }); + + it("accepts local avatar paths for self-profile updates", async () => { + const cfg = { channels: { matrix: { actions: { profile: true } } } } as CoreConfig; + await handleMatrixAction( + { + action: "setProfile", + accountId: "ops", + path: "/tmp/avatar.jpg", + }, + cfg, + ); + + expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({ + cfg, + account: "ops", + displayName: undefined, + avatarUrl: undefined, + avatarPath: "/tmp/avatar.jpg", + }); + }); + + it("respects account-scoped action overrides when gating direct tool actions", async () => { + await expect( + handleMatrixAction( + { + action: "sendMessage", + accountId: "ops", + to: "room:!room:example", + content: "hello", + }, + { + channels: { + matrix: { + actions: { + messages: true, + }, + accounts: { + ops: { + actions: { + messages: false, + }, + }, + }, + }, + }, + } as CoreConfig, + ), + ).rejects.toThrow("Matrix messages are disabled."); + }); +}); diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 4a0b49dc7fe..2003789e502 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -4,27 +4,69 @@ import { jsonResult, readNumberParam, readReactionParams, + readStringArrayParam, readStringParam, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/matrix"; +import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; import { + bootstrapMatrixVerification, + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, deleteMatrixMessage, editMatrixMessage, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, + getMatrixRoomKeyBackupStatus, + getMatrixVerificationStatus, getMatrixMemberInfo, getMatrixRoomInfo, + getMatrixVerificationSas, listMatrixPins, listMatrixReactions, + listMatrixVerifications, + mismatchMatrixVerificationSas, pinMatrixMessage, readMatrixMessages, + requestMatrixVerification, + restoreMatrixRoomKeyBackup, removeMatrixReactions, + scanMatrixVerificationQr, sendMatrixMessage, + startMatrixVerification, unpinMatrixMessage, + voteMatrixPoll, + verifyMatrixRecoveryKey, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; +import { applyMatrixProfileUpdate } from "./profile-update.js"; import type { CoreConfig } from "./types.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +const pollActions = new Set(["pollVote"]); +const profileActions = new Set(["setProfile"]); +const verificationActions = new Set([ + "encryptionStatus", + "verificationList", + "verificationRequest", + "verificationAccept", + "verificationCancel", + "verificationStart", + "verificationGenerateQr", + "verificationScanQr", + "verificationSas", + "verificationConfirm", + "verificationMismatch", + "verificationConfirmQr", + "verificationStatus", + "verificationBootstrap", + "verificationRecoveryKey", + "verificationBackupStatus", + "verificationBackupRestore", +]); function readRoomId(params: Record, required = true): string { const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); @@ -37,12 +79,65 @@ function readRoomId(params: Record, required = true): string { return readStringParam(params, "to", { required: true }); } +function toSnakeCaseKey(key: string): string { + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} + +function readRawParam(params: Record, key: string): unknown { + if (Object.hasOwn(params, key)) { + return params[key]; + } + const snakeKey = toSnakeCaseKey(key); + if (snakeKey !== key && Object.hasOwn(params, snakeKey)) { + return params[snakeKey]; + } + return undefined; +} + +function readNumericArrayParam( + params: Record, + key: string, + options: { integer?: boolean } = {}, +): number[] { + const { integer = false } = options; + const raw = readRawParam(params, key); + if (raw === undefined) { + return []; + } + return (Array.isArray(raw) ? raw : [raw]) + .map((value) => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + return null; + }) + .filter((value): value is number => value !== null) + .map((value) => (integer ? Math.trunc(value) : value)); +} + export async function handleMatrixAction( params: Record, cfg: CoreConfig, + opts: { mediaLocalRoots?: readonly string[] } = {}, ): Promise> { const action = readStringParam(params, "action", { required: true }); - const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions); + const accountId = readStringParam(params, "accountId") ?? undefined; + const isActionEnabled = createActionGate(resolveMatrixAccountConfig({ cfg, accountId }).actions); + const clientOpts = { + cfg, + ...(accountId ? { accountId } : {}), + }; if (reactionActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -56,17 +151,43 @@ export async function handleMatrixAction( }); if (remove || isEmpty) { const result = await removeMatrixReactions(roomId, messageId, { + ...clientOpts, emoji: remove ? emoji : undefined, }); return jsonResult({ ok: true, removed: result.removed }); } - await reactMatrixMessage(roomId, messageId, emoji); + await reactMatrixMessage(roomId, messageId, emoji, clientOpts); return jsonResult({ ok: true, added: emoji }); } - const reactions = await listMatrixReactions(roomId, messageId); + const limit = readNumberParam(params, "limit", { integer: true }); + const reactions = await listMatrixReactions(roomId, messageId, { + ...clientOpts, + limit: limit ?? undefined, + }); return jsonResult({ ok: true, reactions }); } + if (pollActions.has(action)) { + const roomId = readRoomId(params); + const pollId = readStringParam(params, "pollId", { required: true }); + const optionId = readStringParam(params, "pollOptionId"); + const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true }); + const optionIds = [ + ...(readStringArrayParam(params, "pollOptionIds") ?? []), + ...(optionId ? [optionId] : []), + ]; + const optionIndexes = [ + ...readNumericArrayParam(params, "pollOptionIndexes", { integer: true }), + ...(optionIndex !== undefined ? [optionIndex] : []), + ]; + const result = await voteMatrixPoll(roomId, pollId, { + ...clientOpts, + optionIds, + optionIndexes, + }); + return jsonResult({ ok: true, result }); + } + if (messageActions.has(action)) { if (!isActionEnabled("messages")) { throw new Error("Matrix messages are disabled."); @@ -74,18 +195,20 @@ export async function handleMatrixAction( switch (action) { case "sendMessage": { const to = readStringParam(params, "to", { required: true }); + const mediaUrl = readStringParam(params, "mediaUrl"); const content = readStringParam(params, "content", { - required: true, + required: !mediaUrl, allowEmpty: true, }); - const mediaUrl = readStringParam(params, "mediaUrl"); const replyToId = readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo"); const threadId = readStringParam(params, "threadId"); const result = await sendMatrixMessage(to, content, { mediaUrl: mediaUrl ?? undefined, + mediaLocalRoots: opts.mediaLocalRoots, replyToId: replyToId ?? undefined, threadId: threadId ?? undefined, + ...clientOpts, }); return jsonResult({ ok: true, result }); } @@ -93,14 +216,17 @@ export async function handleMatrixAction( const roomId = readRoomId(params); const messageId = readStringParam(params, "messageId", { required: true }); const content = readStringParam(params, "content", { required: true }); - const result = await editMatrixMessage(roomId, messageId, content); + const result = await editMatrixMessage(roomId, messageId, content, clientOpts); return jsonResult({ ok: true, result }); } case "deleteMessage": { const roomId = readRoomId(params); const messageId = readStringParam(params, "messageId", { required: true }); const reason = readStringParam(params, "reason"); - await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined }); + await deleteMatrixMessage(roomId, messageId, { + reason: reason ?? undefined, + ...clientOpts, + }); return jsonResult({ ok: true, deleted: true }); } case "readMessages": { @@ -112,6 +238,7 @@ export async function handleMatrixAction( limit: limit ?? undefined, before: before ?? undefined, after: after ?? undefined, + ...clientOpts, }); return jsonResult({ ok: true, ...result }); } @@ -127,18 +254,37 @@ export async function handleMatrixAction( const roomId = readRoomId(params); if (action === "pinMessage") { const messageId = readStringParam(params, "messageId", { required: true }); - const result = await pinMatrixMessage(roomId, messageId); + const result = await pinMatrixMessage(roomId, messageId, clientOpts); return jsonResult({ ok: true, pinned: result.pinned }); } if (action === "unpinMessage") { const messageId = readStringParam(params, "messageId", { required: true }); - const result = await unpinMatrixMessage(roomId, messageId); + const result = await unpinMatrixMessage(roomId, messageId, clientOpts); return jsonResult({ ok: true, pinned: result.pinned }); } - const result = await listMatrixPins(roomId); + const result = await listMatrixPins(roomId, clientOpts); return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); } + if (profileActions.has(action)) { + if (!isActionEnabled("profile")) { + throw new Error("Matrix profile updates are disabled."); + } + const avatarPath = + readStringParam(params, "avatarPath") ?? + readStringParam(params, "path") ?? + readStringParam(params, "filePath"); + const result = await applyMatrixProfileUpdate({ + cfg, + account: accountId, + displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"), + avatarUrl: readStringParam(params, "avatarUrl"), + avatarPath, + mediaLocalRoots: opts.mediaLocalRoots, + }); + return jsonResult({ ok: true, ...result }); + } + if (action === "memberInfo") { if (!isActionEnabled("memberInfo")) { throw new Error("Matrix member info is disabled."); @@ -147,6 +293,7 @@ export async function handleMatrixAction( const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); const result = await getMatrixMemberInfo(userId, { roomId: roomId ?? undefined, + ...clientOpts, }); return jsonResult({ ok: true, member: result }); } @@ -156,9 +303,161 @@ export async function handleMatrixAction( throw new Error("Matrix room info is disabled."); } const roomId = readRoomId(params); - const result = await getMatrixRoomInfo(roomId); + const result = await getMatrixRoomInfo(roomId, clientOpts); return jsonResult({ ok: true, room: result }); } + if (verificationActions.has(action)) { + if (!isActionEnabled("verification")) { + throw new Error("Matrix verification actions are disabled."); + } + + const requestId = + readStringParam(params, "requestId") ?? + readStringParam(params, "verificationId") ?? + readStringParam(params, "id"); + + if (action === "encryptionStatus") { + const includeRecoveryKey = params.includeRecoveryKey === true; + const status = await getMatrixEncryptionStatus({ includeRecoveryKey, ...clientOpts }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationStatus") { + const includeRecoveryKey = params.includeRecoveryKey === true; + const status = await getMatrixVerificationStatus({ includeRecoveryKey, ...clientOpts }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationBootstrap") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await bootstrapMatrixVerification({ + recoveryKey: recoveryKey ?? undefined, + forceResetCrossSigning: params.forceResetCrossSigning === true, + ...clientOpts, + }); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationRecoveryKey") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await verifyMatrixRecoveryKey( + readStringParam({ recoveryKey }, "recoveryKey", { required: true, trim: false }), + clientOpts, + ); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationBackupStatus") { + const status = await getMatrixRoomKeyBackupStatus(clientOpts); + return jsonResult({ ok: true, status }); + } + if (action === "verificationBackupRestore") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await restoreMatrixRoomKeyBackup({ + recoveryKey: recoveryKey ?? undefined, + ...clientOpts, + }); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationList") { + const verifications = await listMatrixVerifications(clientOpts); + return jsonResult({ ok: true, verifications }); + } + if (action === "verificationRequest") { + const userId = readStringParam(params, "userId"); + const deviceId = readStringParam(params, "deviceId"); + const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined; + const verification = await requestMatrixVerification({ + ownUser, + userId: userId ?? undefined, + deviceId: deviceId ?? undefined, + roomId: roomId ?? undefined, + ...clientOpts, + }); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationAccept") { + const verification = await acceptMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationCancel") { + const reason = readStringParam(params, "reason"); + const code = readStringParam(params, "code"); + const verification = await cancelMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { reason: reason ?? undefined, code: code ?? undefined, ...clientOpts }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationStart") { + const methodRaw = readStringParam(params, "method"); + const method = methodRaw?.trim().toLowerCase(); + if (method && method !== "sas") { + throw new Error( + "Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.", + ); + } + const verification = await startMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { method: "sas", ...clientOpts }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationGenerateQr") { + const qr = await generateMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, ...qr }); + } + if (action === "verificationScanQr") { + const qrDataBase64 = + readStringParam(params, "qrDataBase64") ?? + readStringParam(params, "qrData") ?? + readStringParam(params, "qr"); + const verification = await scanMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationSas") { + const sas = await getMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, sas }); + } + if (action === "verificationConfirm") { + const verification = await confirmMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationMismatch") { + const verification = await mismatchMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationConfirmQr") { + const verification = await confirmMatrixVerificationReciprocateQr( + readStringParam({ requestId }, "requestId", { required: true }), + clientOpts, + ); + return jsonResult({ ok: true, verification }); + } + } + throw new Error(`Unsupported Matrix action: ${action}`); } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index c5a75eccf53..9f5e205a337 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -1,4 +1,4 @@ -import type { DmPolicy, GroupPolicy, SecretInput } from "../runtime-api.js"; +import type { DmPolicy, GroupPolicy, SecretInput } from "openclaw/plugin-sdk/matrix"; export type { DmPolicy, GroupPolicy }; export type ReplyToMode = "off" | "first" | "all"; @@ -35,8 +35,18 @@ export type MatrixActionConfig = { reactions?: boolean; messages?: boolean; pins?: boolean; + profile?: boolean; memberInfo?: boolean; channelInfo?: boolean; + verification?: boolean; +}; + +export type MatrixThreadBindingsConfig = { + enabled?: boolean; + idleHours?: number; + maxAgeHours?: number; + spawnSubagentSessions?: boolean; + spawnAcpSessions?: boolean; }; /** Per-account Matrix config (excludes the accounts field to prevent recursion). */ @@ -59,9 +69,13 @@ export type MatrixConfig = { accessToken?: string; /** Matrix password (used only to fetch access token). */ password?: SecretInput; + /** Optional Matrix device id (recommended when using access tokens + E2EE). */ + deviceId?: string; /** Optional device name when logging in via password. */ deviceName?: string; - /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */ + /** Optional desired Matrix avatar source (mxc:// or http(s) URL). */ + avatarUrl?: string; + /** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */ initialSyncLimit?: number; /** Enable end-to-end encryption (E2EE). Default: false. */ encryption?: boolean; @@ -81,9 +95,21 @@ export type MatrixConfig = { chunkMode?: "length" | "newline"; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; + /** Ack reaction emoji override for this channel/account. */ + ackReaction?: string; + /** Ack reaction scope override for this channel/account. */ + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off"; + /** Inbound reaction notifications for bot-authored Matrix messages. */ + reactionNotifications?: "off" | "own"; + /** Thread/session binding behavior for Matrix room threads. */ + threadBindings?: MatrixThreadBindingsConfig; + /** Whether Matrix should auto-request self verification on startup when unverified. */ + startupVerification?: "off" | "if-unverified"; + /** Cooldown window for automatic startup verification requests. Default: 24 hours. */ + startupVerificationCooldownHours?: number; /** Max outbound media size in MB. */ mediaMaxMb?: number; - /** Auto-join invites (always|allowlist|off). Default: always. */ + /** Auto-join invites (always|allowlist|off). Default: off. */ autoJoin?: "always" | "allowlist" | "off"; /** Allowlist for auto-join invites (room IDs, aliases). */ autoJoinAllowlist?: Array; @@ -112,7 +138,7 @@ export type CoreConfig = { }; messages?: { ackReaction?: string; - ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "off" | "none"; + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off"; }; [key: string]: unknown; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1e36121bfa..e381cdf6d34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -393,24 +393,28 @@ importers: extensions/matrix: dependencies: - '@mariozechner/pi-agent-core': - specifier: 0.60.0 - version: 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 - '@vector-im/matrix-bot-sdk': - specifier: 0.8.0-element.3 - version: 0.8.0-element.3(@cypress/request@3.0.10) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 markdown-it: - specifier: 14.1.1 - version: 14.1.1 + specifier: 14.1.0 + version: 14.1.0 + matrix-js-sdk: + specifier: ^40.1.0 + version: 40.2.0 music-metadata: - specifier: ^11.12.3 + specifier: ^11.11.2 version: 11.12.3 zod: specifier: ^4.3.6 version: 4.3.6 + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. extensions/mattermost: dependencies: @@ -533,7 +537,7 @@ importers: dependencies: '@tloncorp/api': specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 - version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 + version: git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': specifier: 0.2.2 version: 0.2.2 @@ -1153,16 +1157,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@cypress/request-promise@5.0.0': - resolution: {integrity: sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==} - engines: {node: '>=0.10.0'} - peerDependencies: - '@cypress/request': ^3.0.0 - - '@cypress/request@3.0.10': - resolution: {integrity: sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==} - engines: {node: '>= 6'} - '@d-fischer/cache-decorators@4.0.1': resolution: {integrity: sha512-HNYLBLWs/t28GFZZeqdIBqq8f37mqDIFO6xNPof94VjpKvuP6ROqCZGafx88dk5zZUlBfViV9jD8iNNlXfc4CA==} @@ -1927,10 +1921,6 @@ packages: resolution: {integrity: sha512-zhkwx3Wdo27snVfnJWi7l+wyU4XlazkeunTtz4e500GC+ufGOp4C3aIf0XiO5ZOtTE/0lvUiG2bWULR/i4lgUQ==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-agent-core@0.60.0': - resolution: {integrity: sha512-1zQcfFp8r0iwZCxCBQ9/ccFJoagns68cndLPTJJXl1ZqkYirzSld1zBOPxLAgeAKWIz3OX8dB2WQwTJFhmEojQ==} - engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.58.0': resolution: {integrity: sha512-3TrkJ9QcBYFPo4NxYluhd+JQ4M+98RaEkNPMrLFU4wK4GMFVtsL3kp1YJ/oj7X0eqKuuDKbHj6MdoMZeT2TCvA==} engines: {node: '>=20.0.0'} @@ -1963,6 +1953,10 @@ packages: resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} engines: {node: '>= 22'} + '@matrix-org/matrix-sdk-crypto-wasm@17.1.0': + resolution: {integrity: sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==} + engines: {node: '>= 18'} + '@microsoft/agents-activity@1.3.1': resolution: {integrity: sha512-4k44NrfEqXiSg49ofj8geV8ylPocqDLtZKKt0PFL9BvFV0n57X3y1s/fEbsf7Fkl3+P/R2XLyMB5atEGf/eRGg==} engines: {node: '>=20.0.0'} @@ -2916,9 +2910,6 @@ packages: '@scure/bip39@2.0.1': resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} - '@selderee/plugin-htmlparser2@0.11.0': - resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} - '@shikijs/core@3.23.0': resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} @@ -3532,8 +3523,8 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': - resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} + '@tloncorp/api@git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': + resolution: {commit: 7eede1c1a756977b09f96aa14a92e2b06318ae87, repo: git@github.com:tloncorp/api-beta.git, type: git} version: 0.0.2 '@tloncorp/tlon-skill-darwin-arm64@0.2.2': @@ -3605,9 +3596,6 @@ packages: '@types/bun@1.3.9': resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} - '@types/caseless@0.12.5': - resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3626,15 +3614,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + '@types/events@3.0.3': + resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==} '@types/express-serve-static-core@5.1.1': resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} @@ -3668,9 +3653,6 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -3698,30 +3680,18 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/request@2.48.13': - resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} - '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} '@types/sarif@2.1.7': resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} - '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/tough-cookie@4.0.5': - resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} - '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3787,10 +3757,6 @@ packages: '@urbit/nockjs@1.6.0': resolution: {integrity: sha512-f2xCIxoYQh+bp/p6qztvgxnhGsnUwcrSSvW2CUKX7BPPVkDNppQCzCVPWo38TbqgChE7wh6rC1pm6YNCOyFlQA==} - '@vector-im/matrix-bot-sdk@0.8.0-element.3': - resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==} - engines: {node: '>=22.0.0'} - '@vitest/browser-playwright@4.1.0': resolution: {integrity: sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ==} peerDependencies: @@ -3868,8 +3834,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} + '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git} version: 2.0.1 abbrev@1.1.1: @@ -3879,10 +3845,6 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3990,22 +3952,12 @@ packages: resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} engines: {node: '>=12.17'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} - asn1@0.2.6: - resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assert-never@1.4.0: resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -4021,9 +3973,6 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} - async-lock@1.4.1: - resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} - async-mutex@0.5.0: resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} @@ -4051,12 +4000,6 @@ packages: resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} engines: {node: '>=6.0.0'} - aws-sign2@0.7.0: - resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} - - aws4@1.13.2: - resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -4120,20 +4063,16 @@ packages: bare-url@2.3.2: resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} - bcrypt-pbkdf@1.0.2: - resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -4154,16 +4093,9 @@ packages: resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} engines: {node: '>=8.9'} - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - bmp-ts@1.0.9: resolution: {integrity: sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==} - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -4188,6 +4120,9 @@ packages: browser-or-node@3.0.0: resolution: {integrity: sha512-iczIdVJzGEYhP5DqQxYM9Hh7Ztpqqi+CXZpSmX8ALFs9ecXkQIeqRyM6TfxEfMVpwhl3dSuDvxdzzo9sUOIVBQ==} + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -4222,9 +4157,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caseless@0.12.0: - resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4355,10 +4287,6 @@ packages: constantinople@4.0.1: resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4370,9 +4298,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -4381,9 +4306,6 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -4419,10 +4341,6 @@ packages: curve25519-js@0.0.4: resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} - dashdash@1.14.1: - resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} - engines: {node: '>=0.10'} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -4438,14 +4356,6 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4462,10 +4372,6 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -4488,10 +4394,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4548,9 +4450,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ecc-jsbn@0.1.2: - resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} - ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -4624,10 +4523,6 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - escodegen@2.1.0: resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} engines: {node: '>=6.0'} @@ -4666,6 +4561,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -4694,10 +4593,6 @@ packages: peerDependencies: express: '>= 4.11' - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -4710,9 +4605,9 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true - extsprintf@1.3.0: - resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} - engines: {'0': node >=0.6.0} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} @@ -4769,10 +4664,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -4797,9 +4688,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - forever-agent@0.6.1: - resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} - form-data@2.5.4: resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==} engines: {node: '>= 0.12'} @@ -4813,10 +4701,6 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -4889,9 +4773,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - getpass@0.1.7: - resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} - gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} @@ -4903,9 +4784,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -4961,9 +4839,6 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} - hash.js@1.1.7: - resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hashery@1.5.0: resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} engines: {node: '>=20'} @@ -5005,22 +4880,12 @@ packages: html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} - html-to-text@9.0.5: - resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} - engines: {node: '>=14'} - html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - htmlencode@0.0.4: - resolution: {integrity: sha512-0uDvNVpzj/E2TfvLLyyXhKBRvF1y84aZsyRxRXFsQobnHaL4pcaXk+Y9cnFlvnxrBLeXDNq/VJBD+ngdBgQG1w==} - htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5029,10 +4894,6 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http-signature@1.4.0: - resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} - engines: {node: '>=0.10'} - https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -5049,10 +4910,6 @@ packages: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -5138,14 +4995,14 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-network-error@1.3.1: + resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} + engines: {node: '>=16'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -5163,9 +5020,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} @@ -5180,9 +5034,6 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} - isstream@0.1.2: - resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -5221,9 +5072,6 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - jsbn@0.1.1: - resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} - jscpd-sarif-reporter@4.0.6: resolution: {integrity: sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng==} @@ -5262,12 +5110,6 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - json-schema@0.4.0: - resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json-with-bigint@3.5.7: resolution: {integrity: sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==} @@ -5283,10 +5125,6 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} - jsprim@2.0.2: - resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} - engines: {'0': node >=0.6.0} - jstransformer@1.0.0: resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} @@ -5303,6 +5141,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -5316,9 +5158,6 @@ packages: koffi@2.15.2: resolution: {integrity: sha512-r9tjJLVRSOhCRWdVyQlF3/Ugzeg13jlzS4czS82MAgLff4W+BcYOW7g8Y62t9O5JYjYOLAjAovAZDNlDfZNu+g==} - leac@0.6.0: - resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} - libphonenumber-js@1.12.38: resolution: {integrity: sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==} @@ -5471,16 +5310,16 @@ packages: resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} engines: {node: '>=18'} + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + long@4.0.0: resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - lowdb@1.0.0: - resolution: {integrity: sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==} - engines: {node: '>=4'} - lowdb@7.0.1: resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==} engines: {node: '>=18'} @@ -5520,6 +5359,10 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + markdown-it@14.1.1: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true @@ -5541,6 +5384,16 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + matrix-events-sdk@0.0.1: + resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} + + matrix-js-sdk@40.2.0: + resolution: {integrity: sha512-wqb1Oq34WB9r0njxw8XiNsm8DIvYeGfCn3wrVrDwj8HMoTI0TvLSY1sQ+x6J2Eg27abfVwInxLKyxLp+dROFXQ==} + engines: {node: '>=22.0.0'} + + matrix-widget-api@1.17.0: + resolution: {integrity: sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} @@ -5550,17 +5403,10 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -5572,10 +5418,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - micromark-util-character@2.1.1: resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} @@ -5611,11 +5453,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -5629,9 +5466,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -5647,18 +5481,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mkdirp@3.0.1: - resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} - engines: {node: '>=10'} - hasBin: true - module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} - morgan@1.10.1: - resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} - engines: {node: '>= 0.8.0'} - mpg123-decoder@1.0.3: resolution: {integrity: sha512-+fjxnWigodWJm3+4pndi+KUg9TBojgn31DPk85zEsim7C6s0X5Ztc/hQYdytXkwuGXH+aB0/aEkG40Emukv6oQ==} @@ -5666,9 +5491,6 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5689,10 +5511,6 @@ packages: engines: {node: ^18 || >=20} hasBin: true - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -5800,6 +5618,10 @@ packages: ogg-opus-decoder@1.7.3: resolution: {integrity: sha512-w47tiZpkLgdkpa+34VzYD8mHUj8I9kfWVZa82mBbNwDvB1byfLXSSzW/HxA4fI3e9kVlICSpXGFwMLV1LPdjwg==} + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + omggif@1.0.10: resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} @@ -5807,18 +5629,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} - engines: {node: '>= 0.8'} - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5920,6 +5734,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + p-timeout@3.2.0: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} @@ -5962,9 +5780,6 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - parse-srcset@1.0.2: - resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} - parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -5977,9 +5792,6 @@ packages: parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} - parseley@0.12.1: - resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6010,9 +5822,6 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -6023,15 +5832,9 @@ packages: resolution: {integrity: sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==} engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} - peberminta@0.9.0: - resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} - pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6043,10 +5846,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} @@ -6083,18 +5882,10 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} - postgres@3.4.8: - resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} - engines: {node: '>=12'} - pretty-bytes@6.1.1: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} @@ -6239,10 +6030,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -6294,12 +6081,6 @@ packages: reprism@0.0.11: resolution: {integrity: sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA==} - request-promise-core@1.1.3: - resolution: {integrity: sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==} - engines: {node: '>=0.10.0'} - peerDependencies: - request: ^2.34 - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -6392,9 +6173,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize-html@2.17.1: - resolution: {integrity: sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==} - sax@1.6.0: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} @@ -6406,8 +6184,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - selderee@0.11.0: - resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + sdp-transform@3.0.0: + resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==} + hasBin: true semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -6418,18 +6197,10 @@ packages: engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -6620,11 +6391,6 @@ packages: sqlite-vec@0.1.7-alpha.2: resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==} - sshpk@1.18.0: - resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} - engines: {node: '>=0.10.0'} - hasBin: true - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -6646,13 +6412,6 @@ packages: resolution: {integrity: sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==} engines: {node: '>=16.0.0'} - stealthy-require@1.1.1: - resolution: {integrity: sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==} - engines: {node: '>=0.10.0'} - - steno@0.4.4: - resolution: {integrity: sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==} - steno@4.0.2: resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==} engines: {node: '>=18'} @@ -6860,16 +6619,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - tweetnacl@0.14.5: - resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -6917,6 +6666,9 @@ packages: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} + unhomoglyph@1.0.6: + resolution: {integrity: sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -6972,14 +6724,14 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -6996,10 +6748,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verror@1.10.0: - resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} - engines: {'0': node >=0.6.0} - vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -8439,37 +8187,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)': - dependencies: - '@cypress/request': 3.0.10 - bluebird: 3.7.2 - request-promise-core: 1.1.3(@cypress/request@3.0.10) - stealthy-require: 1.1.1 - tough-cookie: 4.1.3 - transitivePeerDependencies: - - request - - '@cypress/request@3.0.10': - dependencies: - aws-sign2: 0.7.0 - aws4: 1.13.2 - caseless: 0.12.0 - combined-stream: 1.0.8 - extend: 3.0.2 - forever-agent: 0.6.1 - form-data: 2.5.4 - http-signature: 1.4.0 - is-typedarray: 1.0.0 - isstream: 0.1.2 - json-stringify-safe: 5.0.1 - mime-types: 2.1.35 - performance-now: 2.1.0 - qs: 6.14.2 - safe-buffer: 5.2.1 - tough-cookie: 4.1.3 - tunnel-agent: 0.6.0 - uuid: 8.3.2 - '@d-fischer/cache-decorators@4.0.1': dependencies: '@d-fischer/shared-utils': 3.6.4 @@ -9333,18 +9050,6 @@ snapshots: - ws - zod - '@mariozechner/pi-agent-core@0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.60.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - '@mariozechner/pi-ai@0.58.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) @@ -9484,6 +9189,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@matrix-org/matrix-sdk-crypto-wasm@17.1.0': {} + '@microsoft/agents-activity@1.3.1': dependencies: debug: 4.4.3 @@ -10322,11 +10029,6 @@ snapshots: '@noble/hashes': 2.0.1 '@scure/base': 2.0.0 - '@selderee/plugin-htmlparser2@0.11.0': - dependencies: - domhandler: 5.0.3 - selderee: 0.11.0 - '@shikijs/core@3.23.0': dependencies: '@shikijs/types': 3.23.0 @@ -11259,7 +10961,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + '@tloncorp/api@git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 @@ -11377,8 +11079,6 @@ snapshots: bun-types: 1.3.9 optional: true - '@types/caseless@0.12.5': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -11396,12 +11096,7 @@ snapshots: '@types/estree@1.0.8': {} - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 25.5.0 - '@types/qs': 6.14.0 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 + '@types/events@3.0.3': {} '@types/express-serve-static-core@5.1.1': dependencies: @@ -11410,13 +11105,6 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 1.2.1 - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.14.0 - '@types/serve-static': 1.15.10 - '@types/express@5.0.6': dependencies: '@types/body-parser': 1.19.6 @@ -11453,8 +11141,6 @@ snapshots: '@types/mime-types@2.1.4': {} - '@types/mime@1.3.5': {} - '@types/ms@2.1.0': {} '@types/node@10.17.60': {} @@ -11480,39 +11166,19 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/request@2.48.13': - dependencies: - '@types/caseless': 0.12.5 - '@types/node': 25.5.0 - '@types/tough-cookie': 4.0.5 - form-data: 2.5.4 - '@types/retry@0.12.0': {} '@types/sarif@2.1.7': {} - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 25.5.0 - '@types/send@1.2.1': dependencies: '@types/node': 25.5.0 - '@types/serve-static@1.15.10': - dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 25.5.0 - '@types/send': 0.17.6 - '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 '@types/node': 25.5.0 - '@types/tough-cookie@4.0.5': {} - '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -11571,31 +11237,6 @@ snapshots: '@urbit/nockjs@1.6.0': {} - '@vector-im/matrix-bot-sdk@0.8.0-element.3(@cypress/request@3.0.10)': - dependencies: - '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 - '@types/express': 4.17.25 - '@types/request': 2.48.13 - another-json: 0.2.0 - async-lock: 1.4.1 - chalk: 4.1.2 - express: 4.22.1 - glob-to-regexp: 0.4.1 - hash.js: 1.1.7 - html-to-text: 9.0.5 - htmlencode: 0.0.4 - lowdb: 1.0.0 - lru-cache: 10.4.3 - mkdirp: 3.0.1 - morgan: 1.10.1 - postgres: 3.4.8 - request: '@cypress/request@3.0.10' - request-promise: '@cypress/request-promise@5.0.0(@cypress/request@3.0.10)(@cypress/request@3.0.10)' - sanitize-html: 2.17.1 - transitivePeerDependencies: - - '@cypress/request' - - supports-color - '@vitest/browser-playwright@4.1.0(playwright@1.58.2)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)': dependencies: '@vitest/browser': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) @@ -11711,7 +11352,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.7 music-metadata: 11.12.3 p-queue: 9.1.0 @@ -11727,7 +11368,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -11739,11 +11380,6 @@ snapshots: dependencies: event-target-shim: 5.0.1 - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -11840,18 +11476,10 @@ snapshots: array-back@6.2.2: {} - array-flatten@1.1.1: {} - asap@2.0.6: {} - asn1@0.2.6: - dependencies: - safer-buffer: 2.1.2 - assert-never@1.4.0: {} - assert-plus@1.0.0: {} - assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -11870,8 +11498,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - async-lock@1.4.1: {} - async-mutex@0.5.0: dependencies: tslib: 2.8.1 @@ -11905,10 +11531,6 @@ snapshots: await-to-js@3.0.0: optional: true - aws-sign2@0.7.0: {} - - aws4@1.13.2: {} - axios@1.13.5: dependencies: follow-redirects: 1.15.11 @@ -11968,18 +11590,12 @@ snapshots: dependencies: bare-path: 3.0.0 + base-x@5.0.1: {} + base64-js@1.5.1: {} - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - basic-ftp@5.2.0: {} - bcrypt-pbkdf@1.0.2: - dependencies: - tweetnacl: 0.14.5 - before-after-hook@4.0.0: {} bidi-js@1.0.3: @@ -11997,28 +11613,9 @@ snapshots: execa: 4.1.0 which: 2.0.2 - bluebird@3.7.2: {} - bmp-ts@1.0.9: optional: true - body-parser@1.20.4: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -12049,6 +11646,10 @@ snapshots: browser-or-node@3.0.0: {} + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -12087,8 +11688,6 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caseless@0.12.0: {} - ccount@2.0.1: {} chai@6.2.2: {} @@ -12221,24 +11820,16 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.0.1: {} content-type@1.0.5: {} convert-source-map@2.0.0: {} - cookie-signature@1.0.7: {} - cookie-signature@1.2.2: {} cookie@0.7.2: {} - core-util-is@1.0.2: {} - core-util-is@1.0.3: {} cors@2.8.6: @@ -12275,10 +11866,6 @@ snapshots: curve25519-js@0.0.4: {} - dashdash@1.14.1: - dependencies: - assert-plus: 1.0.0 - data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -12292,10 +11879,6 @@ snapshots: date-fns@3.6.0: {} - debug@2.6.9: - dependencies: - ms: 2.0.0 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -12304,8 +11887,6 @@ snapshots: deep-extend@0.6.0: {} - deepmerge@4.3.1: {} - defu@6.1.4: {} degenerator@5.0.1: @@ -12323,8 +11904,6 @@ snapshots: dequal@2.0.3: {} - destroy@1.2.0: {} - detect-libc@2.1.2: {} devlop@1.1.0: @@ -12373,11 +11952,6 @@ snapshots: eastasianwidth@0.2.0: {} - ecc-jsbn@0.1.2: - dependencies: - jsbn: 0.1.1 - safer-buffer: 2.1.2 - ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -12456,8 +12030,6 @@ snapshots: escape-html@1.0.3: {} - escape-string-regexp@4.0.0: {} - escodegen@2.1.0: dependencies: esprima: 4.0.1 @@ -12490,6 +12062,8 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + events@3.3.0: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -12520,42 +12094,6 @@ snapshots: express: 5.2.1 ip-address: 10.1.0 - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - express@5.2.1: dependencies: accepts: 2.0.0 @@ -12601,7 +12139,7 @@ snapshots: transitivePeerDependencies: - supports-color - extsprintf@1.3.0: {} + fake-indexeddb@6.2.5: {} fast-content-type-parse@3.0.0: {} @@ -12661,18 +12199,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -12697,8 +12223,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - forever-agent@0.6.1: {} - form-data@2.5.4: dependencies: asynckit: 0.4.0 @@ -12714,8 +12238,6 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} - fresh@2.0.0: {} fs-extra@11.3.3: @@ -12817,10 +12339,6 @@ snapshots: transitivePeerDependencies: - supports-color - getpass@0.1.7: - dependencies: - assert-plus: 1.0.0 - gifwrap@0.10.1: dependencies: image-q: 4.0.0 @@ -12833,8 +12351,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: {} - glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -12911,11 +12427,6 @@ snapshots: has-unicode@2.0.1: optional: true - hash.js@1.1.7: - dependencies: - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - hashery@1.5.0: dependencies: hookified: 1.15.1 @@ -12964,18 +12475,8 @@ snapshots: html-escaper@3.0.3: {} - html-to-text@9.0.5: - dependencies: - '@selderee/plugin-htmlparser2': 0.11.0 - deepmerge: 4.3.1 - dom-serializer: 2.0.0 - htmlparser2: 8.0.2 - selderee: 0.11.0 - html-void-elements@3.0.0: {} - htmlencode@0.0.4: {} - htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -12983,13 +12484,6 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 - htmlparser2@8.0.2: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 4.5.0 - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -13005,12 +12499,6 @@ snapshots: transitivePeerDependencies: - supports-color - http-signature@1.4.0: - dependencies: - assert-plus: 1.0.0 - jsprim: 2.0.2 - sshpk: 1.18.0 - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -13035,10 +12523,6 @@ snapshots: human-signals@1.1.1: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -13141,9 +12625,9 @@ snapshots: is-interactive@2.0.0: {} - is-number@7.0.0: {} + is-network-error@1.3.1: {} - is-plain-object@5.0.0: {} + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -13160,8 +12644,6 @@ snapshots: is-stream@2.0.1: {} - is-typedarray@1.0.0: {} - is-unicode-supported@2.1.0: {} isarray@1.0.0: {} @@ -13170,8 +12652,6 @@ snapshots: isexe@4.0.0: {} - isstream@0.1.2: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -13237,8 +12717,6 @@ snapshots: js-tokens@10.0.0: {} - jsbn@0.1.1: {} - jscpd-sarif-reporter@4.0.6: dependencies: colors: 1.4.0 @@ -13301,10 +12779,6 @@ snapshots: json-schema-typed@8.0.2: {} - json-schema@0.4.0: {} - - json-stringify-safe@5.0.1: {} - json-with-bigint@3.5.7: {} json5@2.2.3: {} @@ -13328,13 +12802,6 @@ snapshots: ms: 2.1.3 semver: 7.7.4 - jsprim@2.0.2: - dependencies: - assert-plus: 1.0.0 - extsprintf: 1.3.0 - json-schema: 0.4.0 - verror: 1.10.0 - jstransformer@1.0.0: dependencies: is-promise: 2.2.2 @@ -13368,6 +12835,8 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + jwt-decode@4.0.0: {} + keyv@5.6.0: dependencies: '@keyv/serialize': 1.1.1 @@ -13380,8 +12849,6 @@ snapshots: koffi@2.15.2: optional: true - leac@0.6.0: {} - libphonenumber-js@1.12.38: {} lie@3.3.0: @@ -13504,18 +12971,12 @@ snapshots: is-unicode-supported: 2.1.0 yoctocolors: 2.1.2 + loglevel@1.9.2: {} + long@4.0.0: {} long@5.3.2: {} - lowdb@1.0.0: - dependencies: - graceful-fs: 4.2.11 - is-promise: 2.2.2 - lodash: 4.17.23 - pify: 3.0.0 - steno: 0.4.4 - lowdb@7.0.1: dependencies: steno: 4.0.2 @@ -13556,6 +13017,15 @@ snapshots: dependencies: semver: 7.7.4 + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-it@14.1.1: dependencies: argparse: 2.0.1 @@ -13575,6 +13045,30 @@ snapshots: math-intrinsics@1.1.0: {} + matrix-events-sdk@0.0.1: {} + + matrix-js-sdk@40.2.0: + dependencies: + '@babel/runtime': 7.29.2 + '@matrix-org/matrix-sdk-crypto-wasm': 17.1.0 + another-json: 0.2.0 + bs58: 6.0.0 + content-type: 1.0.5 + jwt-decode: 4.0.0 + loglevel: 1.9.2 + matrix-events-sdk: 0.0.1 + matrix-widget-api: 1.17.0 + oidc-client-ts: 3.5.0 + p-retry: 7.1.1 + sdp-transform: 3.0.0 + unhomoglyph: 1.0.6 + uuid: 13.0.0 + + matrix-widget-api@1.17.0: + dependencies: + '@types/events': 3.0.3 + events: 3.3.0 + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 @@ -13591,20 +13085,14 @@ snapshots: mdurl@2.0.0: {} - media-typer@0.3.0: {} - media-typer@1.1.0: {} - merge-descriptors@1.0.3: {} - merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} merge2@1.4.1: {} - methods@1.1.2: {} - micromark-util-character@2.1.1: dependencies: micromark-util-symbol: 2.0.1 @@ -13639,8 +13127,6 @@ snapshots: dependencies: mime-db: 1.54.0 - mime@1.6.0: {} - mime@3.0.0: optional: true @@ -13648,8 +13134,6 @@ snapshots: mimic-function@5.0.1: {} - minimalistic-assert@1.0.1: {} - minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -13662,20 +13146,8 @@ snapshots: dependencies: minipass: 7.1.3 - mkdirp@3.0.1: {} - module-details-from-path@1.0.4: {} - morgan@1.10.1: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color - mpg123-decoder@1.0.3: dependencies: '@wasm-audio-decoders/common': 9.0.7 @@ -13683,8 +13155,6 @@ snapshots: mrmime@2.0.1: {} - ms@2.0.0: {} - ms@2.1.3: {} music-metadata@11.12.3: @@ -13712,8 +13182,6 @@ snapshots: nanoid@5.1.7: {} - negotiator@0.6.3: {} - negotiator@1.0.0: {} netmask@2.0.2: {} @@ -13869,21 +13337,19 @@ snapshots: opus-decoder: 0.7.11 optional: true + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + omggif@1.0.10: optional: true on-exit-leak-free@2.1.2: {} - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 - on-finished@2.4.1: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -14084,6 +13550,10 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.1 + p-timeout@3.2.0: dependencies: p-finally: 1.0.0 @@ -14130,8 +13600,6 @@ snapshots: parse-ms@4.0.0: {} - parse-srcset@1.0.2: {} - parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -14144,11 +13612,6 @@ snapshots: dependencies: entities: 6.0.1 - parseley@0.12.1: - dependencies: - leac: 0.6.0 - peberminta: 0.9.0 - parseurl@1.3.3: {} partial-json@0.1.7: {} @@ -14172,8 +13635,6 @@ snapshots: lru-cache: 11.2.7 minipass: 7.1.3 - path-to-regexp@0.1.12: {} - path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -14183,20 +13644,14 @@ snapshots: '@napi-rs/canvas': 0.1.95 node-readable-to-web-readable-stream: 0.4.2 - peberminta@0.9.0: {} - pend@1.2.0: {} - performance-now@2.1.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} - pify@3.0.0: {} - pino-abstract-transport@2.0.0: dependencies: split2: 4.2.0 @@ -14237,20 +13692,12 @@ snapshots: pngjs@7.0.0: {} - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - postgres@3.4.8: {} - pretty-bytes@6.1.1: {} pretty-ms@8.0.0: @@ -14438,13 +13885,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -14503,11 +13943,6 @@ snapshots: reprism@0.0.11: {} - request-promise-core@1.1.3(@cypress/request@3.0.10): - dependencies: - lodash: 4.17.23 - request: '@cypress/request@3.0.10' - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -14610,15 +14045,6 @@ snapshots: safer-buffer@2.1.2: {} - sanitize-html@2.17.1: - dependencies: - deepmerge: 4.3.1 - escape-string-regexp: 4.0.0 - htmlparser2: 8.0.2 - is-plain-object: 5.0.0 - parse-srcset: 1.0.2 - postcss: 8.5.6 - sax@1.6.0: optional: true @@ -14628,33 +14054,13 @@ snapshots: scheduler@0.27.0: {} - selderee@0.11.0: - dependencies: - parseley: 0.12.1 + sdp-transform@3.0.0: {} semver@6.3.1: optional: true semver@7.7.4: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - send@1.2.1: dependencies: debug: 4.4.3 @@ -14671,15 +14077,6 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -14909,18 +14306,6 @@ snapshots: sqlite-vec-linux-x64: 0.1.7-alpha.2 sqlite-vec-windows-x64: 0.1.7-alpha.2 - sshpk@1.18.0: - dependencies: - asn1: 0.2.6 - assert-plus: 1.0.0 - bcrypt-pbkdf: 1.0.2 - dashdash: 1.14.1 - ecc-jsbn: 0.1.2 - getpass: 0.1.7 - jsbn: 0.1.1 - safer-buffer: 2.1.2 - tweetnacl: 0.14.5 - stackback@0.0.2: {} statuses@2.0.2: {} @@ -14938,12 +14323,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - stealthy-require@1.1.1: {} - - steno@0.4.4: - dependencies: - graceful-fs: 4.2.11 - steno@4.0.2: {} streamx@2.23.0: @@ -15162,17 +14541,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - tweetnacl@0.14.5: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -15206,6 +14574,8 @@ snapshots: undici@7.24.4: {} + unhomoglyph@1.0.6: {} + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -15257,10 +14627,10 @@ snapshots: util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@8.3.2: {} validate-npm-package-name@7.0.2: {} @@ -15269,12 +14639,6 @@ snapshots: vary@1.1.2: {} - verror@1.10.0: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.3.0 - vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index c53584cdf55..d11b569602c 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -1,6 +1,20 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as acpSessionManager from "../acp/control-plane/manager.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../config/config.js"; +import * as sessionConfig from "../config/sessions.js"; +import * as sessionTranscript from "../config/sessions/transcript.js"; +import * as gatewayCall from "../gateway/call.js"; +import * as heartbeatWake from "../infra/heartbeat-wake.js"; +import { + __testing as sessionBindingServiceTesting, + registerSessionBindingAdapter, + type SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; +import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js"; function createDefaultSpawnConfig(): OpenClawConfig { return { @@ -26,7 +40,6 @@ function createDefaultSpawnConfig(): OpenClawConfig { const hoisted = vi.hoisted(() => { const callGatewayMock = vi.fn(); - const sessionBindingCapabilitiesMock = vi.fn(); const sessionBindingBindMock = vi.fn(); const sessionBindingUnbindMock = vi.fn(); const sessionBindingResolveByConversationMock = vi.fn(); @@ -44,7 +57,6 @@ const hoisted = vi.hoisted(() => { }; return { callGatewayMock, - sessionBindingCapabilitiesMock, sessionBindingBindMock, sessionBindingUnbindMock, sessionBindingResolveByConversationMock, @@ -61,92 +73,32 @@ const hoisted = vi.hoisted(() => { }; }); -function buildSessionBindingServiceMock() { - return { - touch: vi.fn(), - bind(input: unknown) { - return hoisted.sessionBindingBindMock(input); - }, - unbind(input: unknown) { - return hoisted.sessionBindingUnbindMock(input); - }, - getCapabilities(params: unknown) { - return hoisted.sessionBindingCapabilitiesMock(params); - }, - resolveByConversation(ref: unknown) { - return hoisted.sessionBindingResolveByConversationMock(ref); - }, - listBySession(targetSessionKey: string) { - return hoisted.sessionBindingListBySessionMock(targetSessionKey); - }, - }; -} - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => hoisted.state.cfg, - }; -}); - -vi.mock("../gateway/call.js", () => ({ - callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), -})); - -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), - resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts), - }; -}); - -vi.mock("../config/sessions/transcript.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveSessionTranscriptFile: (params: unknown) => - hoisted.resolveSessionTranscriptFileMock(params), - }; -}); - -vi.mock("../acp/control-plane/manager.js", () => { - return { - getAcpSessionManager: () => ({ - initializeSession: (params: unknown) => hoisted.initializeSessionMock(params), - closeSession: (params: unknown) => hoisted.closeSessionMock(params), - }), - }; -}); - -vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - getSessionBindingService: () => buildSessionBindingServiceMock(), - }; -}); - -vi.mock("../infra/heartbeat-wake.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - areHeartbeatsEnabled: () => hoisted.areHeartbeatsEnabledMock(), - }; -}); - -vi.mock("./acp-spawn-parent-stream.js", () => ({ - startAcpSpawnParentStreamRelay: (...args: unknown[]) => - hoisted.startAcpSpawnParentStreamRelayMock(...args), - resolveAcpSpawnStreamLogPath: (...args: unknown[]) => - hoisted.resolveAcpSpawnStreamLogPathMock(...args), -})); +const callGatewaySpy = vi.spyOn(gatewayCall, "callGateway"); +const getAcpSessionManagerSpy = vi.spyOn(acpSessionManager, "getAcpSessionManager"); +const loadSessionStoreSpy = vi.spyOn(sessionConfig, "loadSessionStore"); +const resolveStorePathSpy = vi.spyOn(sessionConfig, "resolveStorePath"); +const resolveSessionTranscriptFileSpy = vi.spyOn(sessionTranscript, "resolveSessionTranscriptFile"); +const areHeartbeatsEnabledSpy = vi.spyOn(heartbeatWake, "areHeartbeatsEnabled"); +const startAcpSpawnParentStreamRelaySpy = vi.spyOn( + acpSpawnParentStream, + "startAcpSpawnParentStreamRelay", +); +const resolveAcpSpawnStreamLogPathSpy = vi.spyOn( + acpSpawnParentStream, + "resolveAcpSpawnStreamLogPath", +); const { spawnAcpDirect } = await import("./acp-spawn.js"); +function replaceSpawnConfig(next: OpenClawConfig): void { + const current = hoisted.state.cfg as Record; + for (const key of Object.keys(current)) { + delete current[key]; + } + Object.assign(current, next); + setRuntimeConfigSnapshot(hoisted.state.cfg); +} + function createSessionBindingCapabilities() { return { adapterAvailable: true, @@ -201,10 +153,11 @@ function expectResolvedIntroTextInBindMetadata(): void { describe("spawnAcpDirect", () => { beforeEach(() => { - hoisted.state.cfg = createDefaultSpawnConfig(); + replaceSpawnConfig(createDefaultSpawnConfig()); hoisted.areHeartbeatsEnabledMock.mockReset().mockReturnValue(true); - hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { + hoisted.callGatewayMock.mockReset(); + hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { method?: string }; if (args.method === "sessions.patch") { return { ok: true }; @@ -217,11 +170,18 @@ describe("spawnAcpDirect", () => { } return {}; }); + callGatewaySpy.mockReset().mockImplementation(async (argsUnknown: unknown) => { + return await hoisted.callGatewayMock(argsUnknown); + }); hoisted.closeSessionMock.mockReset().mockResolvedValue({ runtimeClosed: true, metaCleared: false, }); + getAcpSessionManagerSpy.mockReset().mockReturnValue({ + initializeSession: async (params) => await hoisted.initializeSessionMock(params), + closeSession: async (params) => await hoisted.closeSessionMock(params), + } as unknown as ReturnType); hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { sessionKey: string; @@ -262,9 +222,6 @@ describe("spawnAcpDirect", () => { }; }); - hoisted.sessionBindingCapabilitiesMock - .mockReset() - .mockReturnValue(createSessionBindingCapabilities()); hoisted.sessionBindingBindMock .mockReset() .mockImplementation( @@ -292,13 +249,33 @@ describe("spawnAcpDirect", () => { hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); + sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + capabilities: createSessionBindingCapabilities(), + bind: async (input) => await hoisted.sessionBindingBindMock(input), + listBySession: (targetSessionKey) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), + unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), + }); hoisted.startAcpSpawnParentStreamRelayMock .mockReset() .mockImplementation(() => createRelayHandle()); + startAcpSpawnParentStreamRelaySpy + .mockReset() + .mockImplementation((...args) => hoisted.startAcpSpawnParentStreamRelayMock(...args)); hoisted.resolveAcpSpawnStreamLogPathMock .mockReset() .mockReturnValue("/tmp/sess-main.acp-stream.jsonl"); + resolveAcpSpawnStreamLogPathSpy + .mockReset() + .mockImplementation((...args) => hoisted.resolveAcpSpawnStreamLogPathMock(...args)); hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json"); + resolveStorePathSpy + .mockReset() + .mockImplementation((store, opts) => hoisted.resolveStorePathMock(store, opts)); hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { const store: Record = {}; return new Proxy(store, { @@ -310,6 +287,9 @@ describe("spawnAcpDirect", () => { }, }); }); + loadSessionStoreSpy + .mockReset() + .mockImplementation((storePath) => hoisted.loadSessionStoreMock(storePath)); hoisted.resolveSessionTranscriptFileMock .mockReset() .mockImplementation(async (params: unknown) => { @@ -326,6 +306,17 @@ describe("spawnAcpDirect", () => { }, }; }); + resolveSessionTranscriptFileSpy + .mockReset() + .mockImplementation(async (params) => await hoisted.resolveSessionTranscriptFileMock(params)); + areHeartbeatsEnabledSpy + .mockReset() + .mockImplementation(() => hoisted.areHeartbeatsEnabledMock()); + }); + + afterEach(() => { + sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); + clearRuntimeConfigSnapshot(); }); it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { @@ -386,6 +377,85 @@ describe("spawnAcpDirect", () => { expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); + it("spawns Matrix thread-bound ACP sessions from top-level room targets", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + channels: { + ...hoisted.state.cfg.channels, + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + }); + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "default", + capabilities: createSessionBindingCapabilities(), + bind: async (input) => await hoisted.sessionBindingBindMock(input), + listBySession: (targetSessionKey) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), + unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), + }); + hoisted.sessionBindingBindMock.mockImplementationOnce( + async (input: { + targetSessionKey: string; + conversation: { accountId: string; conversationId: string; parentConversationId?: string }; + metadata?: Record; + }) => + createSessionBinding({ + targetSessionKey: input.targetSessionKey, + conversation: { + channel: "matrix", + accountId: input.conversation.accountId, + conversationId: "child-thread", + parentConversationId: input.conversation.parentConversationId ?? "!room:example", + }, + metadata: { + boundBy: + typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", + agentId: "codex", + webhookId: "wh-1", + }, + }), + ); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + mode: "session", + thread: true, + }, + { + agentSessionKey: "agent:main:matrix:channel:!room:example", + agentChannel: "matrix", + agentAccountId: "default", + agentTo: "room:!room:example", + }, + ); + expect(result.status).toBe("accepted"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "default", + conversationId: "!room:example", + }), + }), + ); + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.channel).toBe("matrix"); + expect(agentCall?.params?.to).toBe("room:!room:example"); + expect(agentCall?.params?.threadId).toBe("child-thread"); + }); + it("does not inline delivery for fresh oneshot ACP runs", async () => { const result = await spawnAcpDirect( { @@ -476,14 +546,14 @@ describe("spawnAcpDirect", () => { }); it("rejects disallowed ACP agents", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, acp: { enabled: true, backend: "acpx", allowedAgents: ["claudecode"], }, - }; + }); const result = await spawnAcpDirect( { @@ -515,7 +585,7 @@ describe("spawnAcpDirect", () => { }); it("fails fast when Discord ACP thread spawn is disabled", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, channels: { discord: { @@ -525,7 +595,7 @@ describe("spawnAcpDirect", () => { }, }, }, - }; + }); const result = await spawnAcpDirect( { @@ -546,14 +616,14 @@ describe("spawnAcpDirect", () => { }); it("forbids ACP spawn from sandboxed requester sessions", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, agents: { defaults: { sandbox: { mode: "all" }, }, }, - }; + }); const result = await spawnAcpDirect( { @@ -647,7 +717,7 @@ describe("spawnAcpDirect", () => { }); it("implicitly streams mode=run ACP spawns for subagent requester sessions", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, agents: { defaults: { @@ -657,7 +727,7 @@ describe("spawnAcpDirect", () => { }, }, }, - }; + }); const firstHandle = createRelayHandle(); const secondHandle = createRelayHandle(); hoisted.startAcpSpawnParentStreamRelayMock @@ -725,7 +795,7 @@ describe("spawnAcpDirect", () => { }); it("does not implicitly stream when heartbeat target is not session-local", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, agents: { defaults: { @@ -736,7 +806,7 @@ describe("spawnAcpDirect", () => { }, }, }, - }; + }); const result = await spawnAcpDirect( { @@ -755,7 +825,7 @@ describe("spawnAcpDirect", () => { }); it("does not implicitly stream when session scope is global", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, session: { ...hoisted.state.cfg.session, @@ -769,7 +839,7 @@ describe("spawnAcpDirect", () => { }, }, }, - }; + }); const result = await spawnAcpDirect( { @@ -788,12 +858,12 @@ describe("spawnAcpDirect", () => { }); it("does not implicitly stream for subagent requester sessions when heartbeat is disabled", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, agents: { list: [{ id: "main", heartbeat: { every: "30m" } }, { id: "research" }], }, - }; + }); const result = await spawnAcpDirect( { @@ -812,7 +882,7 @@ describe("spawnAcpDirect", () => { }); it("does not implicitly stream for subagent requester sessions when heartbeat cadence is invalid", async () => { - hoisted.state.cfg = { + replaceSpawnConfig({ ...hoisted.state.cfg, agents: { list: [ @@ -822,7 +892,7 @@ describe("spawnAcpDirect", () => { }, ], }, - }; + }); const result = await spawnAcpDirect( { @@ -963,6 +1033,28 @@ describe("spawnAcpDirect", () => { }); it("keeps inline delivery for thread-bound ACP session mode", async () => { + replaceSpawnConfig({ + ...hoisted.state.cfg, + channels: { + ...hoisted.state.cfg.channels, + telegram: { + threadBindings: { + spawnAcpSessions: true, + }, + }, + }, + }); + registerSessionBindingAdapter({ + channel: "telegram", + accountId: "default", + capabilities: createSessionBindingCapabilities(), + bind: async (input) => await hoisted.sessionBindingBindMock(input), + listBySession: (targetSessionKey) => + hoisted.sessionBindingListBySessionMock(targetSessionKey), + resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), + unbind: async (input) => await hoisted.sessionBindingUnbindMock(input), + }); + const result = await spawnAcpDirect( { task: "Investigate flaky tests", diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 9d68a234aea..1e9a72fff8b 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -41,7 +41,12 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../routing/session-key.js"; -import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { + deliveryContextFromSession, + formatConversationTarget, + normalizeDeliveryContext, + resolveConversationDeliveryTarget, +} from "../utils/delivery-context.js"; import { type AcpSpawnParentRelayHandle, resolveAcpSpawnStreamLogPath, @@ -666,9 +671,19 @@ export async function spawnAcpDirect( const fallbackThreadId = fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined; const deliveryThreadId = boundThreadId ?? fallbackThreadId; - const inferredDeliveryTo = boundThreadId - ? `channel:${boundThreadId}` - : requesterOrigin?.to?.trim() || (deliveryThreadId ? `channel:${deliveryThreadId}` : undefined); + const boundDeliveryTarget = resolveConversationDeliveryTarget({ + channel: requesterOrigin?.channel ?? binding?.conversation.channel, + conversationId: binding?.conversation.conversationId, + parentConversationId: binding?.conversation.parentConversationId, + }); + const inferredDeliveryTo = + boundDeliveryTarget.to ?? + requesterOrigin?.to?.trim() ?? + formatConversationTarget({ + channel: requesterOrigin?.channel, + conversationId: deliveryThreadId, + }); + const resolvedDeliveryThreadId = boundDeliveryTarget.threadId ?? deliveryThreadId; const hasDeliveryTarget = Boolean(requesterOrigin?.channel && inferredDeliveryTo); // Fresh one-shot ACP runs should bootstrap the worker first, then let higher layers // decide how to relay status. Inline delivery is reserved for thread-bound sessions. @@ -703,7 +718,7 @@ export async function spawnAcpDirect( channel: useInlineDelivery ? requesterOrigin?.channel : undefined, to: useInlineDelivery ? inferredDeliveryTo : undefined, accountId: useInlineDelivery ? (requesterOrigin?.accountId ?? undefined) : undefined, - threadId: useInlineDelivery ? deliveryThreadId : undefined, + threadId: useInlineDelivery ? resolvedDeliveryThreadId : undefined, idempotencyKey: childIdem, deliver: useInlineDelivery, label: params.label || undefined, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 2a74dab1ef9..7e83742b5ce 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -1,9 +1,21 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { + clearRuntimeConfigSnapshot, + setRuntimeConfigSnapshot, + type OpenClawConfig, +} from "../config/config.js"; +import * as configSessions from "../config/sessions.js"; +import * as gatewayCall from "../gateway/call.js"; import { __testing as sessionBindingServiceTesting, registerSessionBindingAdapter, } from "../infra/outbound/session-binding-service.js"; +import * as hookRunnerGlobal from "../plugins/hook-runner-global.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import * as piEmbedded from "./pi-embedded.js"; +import * as agentStep from "./tools/agent-step.js"; type AgentCallRequest = { method?: string; params?: Record }; type RequesterResolution = { @@ -39,6 +51,17 @@ type MockSubagentRun = { const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" })); const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" })); const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined); +const loadSessionStoreSpy = vi.spyOn(configSessions, "loadSessionStore"); +const resolveAgentIdFromSessionKeySpy = vi.spyOn(configSessions, "resolveAgentIdFromSessionKey"); +const resolveStorePathSpy = vi.spyOn(configSessions, "resolveStorePath"); +const resolveMainSessionKeySpy = vi.spyOn(configSessions, "resolveMainSessionKey"); +const callGatewaySpy = vi.spyOn(gatewayCall, "callGateway"); +const getGlobalHookRunnerSpy = vi.spyOn(hookRunnerGlobal, "getGlobalHookRunner"); +const readLatestAssistantReplySpy = vi.spyOn(agentStep, "readLatestAssistantReply"); +const isEmbeddedPiRunActiveSpy = vi.spyOn(piEmbedded, "isEmbeddedPiRunActive"); +const isEmbeddedPiRunStreamingSpy = vi.spyOn(piEmbedded, "isEmbeddedPiRunStreaming"); +const queueEmbeddedPiMessageSpy = vi.spyOn(piEmbedded, "queueEmbeddedPiMessage"); +const waitForEmbeddedPiRunEndSpy = vi.spyOn(piEmbedded, "waitForEmbeddedPiRunEnd"); const readLatestAssistantReplyMock = vi.fn( async (_sessionKey?: string): Promise => "raw subagent reply", ); @@ -48,20 +71,22 @@ const embeddedRunMock = { queueEmbeddedPiMessage: vi.fn(() => false), waitForEmbeddedPiRunEnd: vi.fn(async () => true), }; -const subagentRegistryMock = { - isSubagentSessionRunActive: vi.fn(() => true), - shouldIgnorePostCompletionAnnounceForSession: vi.fn((_sessionKey: string) => false), - countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0), - countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0), - countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0), - listSubagentRunsForRequester: vi.fn( - (_sessionKey: string, _scope?: { requesterRunId?: string }): MockSubagentRun[] => [], - ), - replaceSubagentRunAfterSteer: vi.fn( - (_params: { previousRunId: string; nextRunId: string }) => true, - ), - resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null), -}; +const { subagentRegistryMock } = vi.hoisted(() => ({ + subagentRegistryMock: { + isSubagentSessionRunActive: vi.fn(() => true), + shouldIgnorePostCompletionAnnounceForSession: vi.fn((_sessionKey: string) => false), + countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0), + countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0), + countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0), + listSubagentRunsForRequester: vi.fn( + (_sessionKey: string, _scope?: { requesterRunId?: string }): MockSubagentRun[] => [], + ), + replaceSubagentRunAfterSteer: vi.fn( + (_params: { previousRunId: string; nextRunId: string }) => true, + ), + resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null), + }, +})); const subagentDeliveryTargetHookMock = vi.fn( async (_event?: unknown, _ctx?: unknown): Promise => undefined, @@ -79,7 +104,7 @@ const chatHistoryMock = vi.fn(async (_sessionKey?: string) => ({ messages: [] as Array, })); let sessionStore: Record> = {}; -let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = { +let configOverride: OpenClawConfig = { session: { mainKey: "main", scope: "per-sender", @@ -101,6 +126,11 @@ async function getSingleAgentCallParams() { return call?.params ?? {}; } +function setConfigOverride(next: OpenClawConfig): void { + configOverride = next; + setRuntimeConfigSnapshot(configOverride); +} + function loadSessionStoreFixture(): Record> { return new Proxy(sessionStore, { get(target, key: string | symbol) { @@ -112,67 +142,13 @@ function loadSessionStoreFixture(): Record> { }); } -vi.mock("../gateway/call.js", () => ({ - callGateway: vi.fn(async (req: unknown) => { - const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } }; - if (typed.method === "agent") { - return await agentSpy(typed); - } - if (typed.method === "send") { - return await sendSpy(typed); - } - if (typed.method === "agent.wait") { - return { status: "error", startedAt: 10, endedAt: 20, error: "boom" }; - } - if (typed.method === "chat.history") { - return await chatHistoryMock(typed.params?.sessionKey); - } - if (typed.method === "sessions.patch") { - return {}; - } - if (typed.method === "sessions.delete") { - sessionsDeleteSpy(typed); - return {}; - } - return {}; - }), -})); - -vi.mock("./tools/agent-step.js", () => ({ - readLatestAssistantReply: readLatestAssistantReplyMock, -})); - -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadSessionStore: vi.fn(() => loadSessionStoreFixture()), - resolveAgentIdFromSessionKey: () => "main", - resolveStorePath: () => "/tmp/sessions.json", - resolveMainSessionKey: () => "agent:main:main", - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), - }; -}); - -vi.mock("./pi-embedded.js", () => embeddedRunMock); - vi.mock("./subagent-registry.js", () => subagentRegistryMock); -vi.mock("../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookRunnerMock, -})); - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => configOverride, - }; -}); +vi.mock("./subagent-registry-runtime.js", () => subagentRegistryMock); describe("subagent announce formatting", () => { let previousFastTestEnv: string | undefined; let runSubagentAnnounceFlow: (typeof import("./subagent-announce.js"))["runSubagentAnnounceFlow"]; + let matrixPlugin: (typeof import("../../extensions/matrix/src/channel.js"))["matrixPlugin"]; beforeAll(async () => { // Set FAST_TEST_MODE before importing the module to ensure the module-level @@ -181,10 +157,12 @@ describe("subagent announce formatting", () => { // See: https://github.com/openclaw/openclaw/issues/31298 previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; process.env.OPENCLAW_TEST_FAST = "1"; + ({ matrixPlugin } = await import("../../extensions/matrix/src/channel.js")); ({ runSubagentAnnounceFlow } = await import("./subagent-announce.js")); }); afterAll(() => { + clearRuntimeConfigSnapshot(); if (previousFastTestEnv === undefined) { delete process.env.OPENCLAW_TEST_FAST; return; @@ -202,6 +180,51 @@ describe("subagent announce formatting", () => { .mockClear() .mockImplementation(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" })); sessionsDeleteSpy.mockClear().mockImplementation((_req: AgentCallRequest) => undefined); + callGatewaySpy.mockReset().mockImplementation(async (req: unknown) => { + const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } }; + if (typed.method === "agent") { + return await agentSpy(typed); + } + if (typed.method === "send") { + return await sendSpy(typed); + } + if (typed.method === "agent.wait") { + return { status: "error", startedAt: 10, endedAt: 20, error: "boom" }; + } + if (typed.method === "chat.history") { + return await chatHistoryMock(typed.params?.sessionKey); + } + if (typed.method === "sessions.patch") { + return {}; + } + if (typed.method === "sessions.delete") { + sessionsDeleteSpy(typed); + return {}; + } + return {}; + }); + loadSessionStoreSpy.mockReset().mockImplementation(() => loadSessionStoreFixture()); + resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main"); + resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json"); + resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main"); + getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock); + readLatestAssistantReplySpy + .mockReset() + .mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey)); + isEmbeddedPiRunActiveSpy + .mockReset() + .mockImplementation(() => embeddedRunMock.isEmbeddedPiRunActive()); + isEmbeddedPiRunStreamingSpy + .mockReset() + .mockImplementation(() => embeddedRunMock.isEmbeddedPiRunStreaming()); + queueEmbeddedPiMessageSpy + .mockReset() + .mockImplementation((...args) => embeddedRunMock.queueEmbeddedPiMessage(...args)); + waitForEmbeddedPiRunEndSpy + .mockReset() + .mockImplementation( + async (...args) => await embeddedRunMock.waitForEmbeddedPiRunEnd(...args), + ); embeddedRunMock.isEmbeddedPiRunActive.mockClear().mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockClear().mockReturnValue(false); embeddedRunMock.queueEmbeddedPiMessage.mockClear().mockReturnValue(false); @@ -232,12 +255,15 @@ describe("subagent announce formatting", () => { chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); sessionStore = {}; sessionBindingServiceTesting.resetSessionBindingAdaptersForTests(); - configOverride = { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), + ); + setConfigOverride({ session: { mainKey: "main", scope: "per-sender", }, - }; + }); }); it("sends instructional message to main agent with status and findings", async () => { @@ -835,6 +861,65 @@ describe("subagent announce formatting", () => { expect(directTargets).not.toContain("channel:main-parent-channel"); }); + it("routes Matrix bound completion delivery to room targets", async () => { + sessionStore = { + "agent:main:subagent:matrix-child": { + sessionId: "child-session-matrix", + }, + "agent:main:main": { + sessionId: "requester-session-matrix", + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "matrix bound answer" }] }], + }); + subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:main" ? 1 : 0, + ); + registerSessionBindingAdapter({ + channel: "matrix", + accountId: "acct-matrix", + listBySession: (targetSessionKey: string) => + targetSessionKey === "agent:main:subagent:matrix-child" + ? [ + { + bindingId: "matrix:acct-matrix:$thread-bound-1", + targetSessionKey, + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "acct-matrix", + conversationId: "$thread-bound-1", + parentConversationId: "!room:example", + }, + status: "active", + boundAt: Date.now(), + }, + ] + : [], + resolveByConversation: () => null, + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:matrix-child", + childRunId: "run-session-bound-matrix", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { channel: "matrix", to: "room:!room:example", accountId: "acct-matrix" }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + spawnMode: "session", + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("matrix"); + expect(call?.params?.to).toBe("room:!room:example"); + expect(call?.params?.threadId).toBe("$thread-bound-1"); + }); + it("includes completion status details for error and timeout outcomes", async () => { const cases = [ { diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index 5070b204392..eeef9db6b9b 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -10,6 +10,7 @@ import { } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js"; +import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js"; @@ -21,6 +22,7 @@ import { deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, + resolveConversationDeliveryTarget, } from "../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL, @@ -537,7 +539,11 @@ async function resolveSubagentCompletionOrigin(params: { ? String(requesterOrigin.threadId).trim() : undefined; const conversationId = - threadId || (to?.startsWith("channel:") ? to.slice("channel:".length) : ""); + threadId || + resolveConversationIdFromTargets({ + targets: [to], + }) || + ""; const requesterConversation: ConversationRef | undefined = channel && conversationId ? { channel, accountId, conversationId } : undefined; @@ -548,15 +554,21 @@ async function resolveSubagentCompletionOrigin(params: { failClosed: false, }); if (route.mode === "bound" && route.binding) { + const boundTarget = resolveConversationDeliveryTarget({ + channel: route.binding.conversation.channel, + conversationId: route.binding.conversation.conversationId, + parentConversationId: route.binding.conversation.parentConversationId, + }); return mergeDeliveryContext( { channel: route.binding.conversation.channel, accountId: route.binding.conversation.accountId, - to: `channel:${route.binding.conversation.conversationId}`, + to: boundTarget.to, threadId: - requesterOrigin?.threadId != null && requesterOrigin.threadId !== "" + boundTarget.threadId ?? + (requesterOrigin?.threadId != null && requesterOrigin.threadId !== "" ? String(requesterOrigin.threadId) - : undefined, + : undefined), }, requesterOrigin, ); diff --git a/src/auto-reply/reply/matrix-context.ts b/src/auto-reply/reply/matrix-context.ts new file mode 100644 index 00000000000..8689cc79d57 --- /dev/null +++ b/src/auto-reply/reply/matrix-context.ts @@ -0,0 +1,54 @@ +type MatrixConversationParams = { + ctx: { + MessageThreadId?: string | number | null; + OriginatingTo?: string; + To?: string; + }; + command: { + to?: string; + }; +}; + +function normalizeMatrixTarget(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function resolveMatrixRoomIdFromTarget(raw: string): string | undefined { + let target = normalizeMatrixTarget(raw); + if (!target) { + return undefined; + } + if (target.toLowerCase().startsWith("matrix:")) { + target = target.slice("matrix:".length).trim(); + } + if (/^(room|channel):/i.test(target)) { + const roomId = target.replace(/^(room|channel):/i, "").trim(); + return roomId || undefined; + } + if (target.startsWith("!") || target.startsWith("#")) { + return target; + } + return undefined; +} + +export function resolveMatrixParentConversationId( + params: MatrixConversationParams, +): string | undefined { + const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To]; + for (const candidate of targets) { + const roomId = resolveMatrixRoomIdFromTarget(candidate ?? ""); + if (roomId) { + return roomId; + } + } + return undefined; +} + +export function resolveMatrixConversationId(params: MatrixConversationParams): string | undefined { + const threadId = + params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + if (threadId) { + return threadId; + } + return resolveMatrixParentConversationId(params); +} diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 4111986e175..2ccf7648c68 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -5,6 +5,7 @@ import { applySetupAccountConfigPatch, createEnvPatchedAccountSetupAdapter, createPatchedAccountSetupAdapter, + moveSingleAccountChannelSectionToDefaultAccount, prepareScopedSetupConfig, } from "./setup-helpers.js"; @@ -163,6 +164,81 @@ describe("createPatchedAccountSetupAdapter", () => { }); }); +describe("moveSingleAccountChannelSectionToDefaultAccount", () => { + it("promotes legacy Matrix keys into the sole named account when defaultAccount is unset", () => { + const next = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: asConfig({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + accounts: { + main: { + enabled: true, + }, + }, + }, + }, + }), + channelKey: "matrix", + }); + + expect(next.channels?.matrix).toMatchObject({ + accounts: { + main: { + enabled: true, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + }, + }, + }); + expect(next.channels?.matrix?.accounts?.default).toBeUndefined(); + expect(next.channels?.matrix?.homeserver).toBeUndefined(); + expect(next.channels?.matrix?.userId).toBeUndefined(); + expect(next.channels?.matrix?.accessToken).toBeUndefined(); + }); + + it("promotes legacy Matrix keys into an existing non-canonical default account key", () => { + const next = moveSingleAccountChannelSectionToDefaultAccount({ + cfg: asConfig({ + channels: { + matrix: { + defaultAccount: "ops", + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "token", + accounts: { + Ops: { + enabled: true, + }, + }, + }, + }, + }), + channelKey: "matrix", + }); + + expect(next.channels?.matrix).toMatchObject({ + defaultAccount: "ops", + accounts: { + Ops: { + enabled: true, + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + accessToken: "token", + }, + }, + }); + expect(next.channels?.matrix?.accounts?.ops).toBeUndefined(); + expect(next.channels?.matrix?.accounts?.default).toBeUndefined(); + expect(next.channels?.matrix?.homeserver).toBeUndefined(); + expect(next.channels?.matrix?.userId).toBeUndefined(); + expect(next.channels?.matrix?.accessToken).toBeUndefined(); + }); +}); + describe("createEnvPatchedAccountSetupAdapter", () => { it("rejects env mode for named accounts and requires credentials otherwise", () => { const adapter = createEnvPatchedAccountSetupAdapter({ diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index e27f13e383a..269bffe7565 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -5,6 +5,7 @@ import type { ChannelSetupInput } from "./types.core.js"; type ChannelSectionBase = { name?: string; + defaultAccount?: string; accounts?: Record>; }; @@ -335,9 +336,73 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ ]); const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record> = { + matrix: new Set([ + "deviceId", + "avatarUrl", + "initialSyncLimit", + "encryption", + "allowlistOnly", + "replyToMode", + "threadReplies", + "textChunkLimit", + "chunkMode", + "responsePrefix", + "ackReaction", + "ackReactionScope", + "reactionNotifications", + "threadBindings", + "startupVerification", + "startupVerificationCooldownHours", + "mediaMaxMb", + "autoJoin", + "autoJoinAllowlist", + "dm", + "groups", + "rooms", + "actions", + ]), telegram: new Set(["streaming"]), }; +const MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS = new Set([ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "avatarUrl", + "initialSyncLimit", + "encryption", +]); + +export const MATRIX_SHARED_MULTI_ACCOUNT_DEFAULT_KEYS = new Set([ + "dmPolicy", + "allowFrom", + "groupPolicy", + "groupAllowFrom", + "allowlistOnly", + "replyToMode", + "threadReplies", + "textChunkLimit", + "chunkMode", + "responsePrefix", + "ackReaction", + "ackReactionScope", + "reactionNotifications", + "threadBindings", + "startupVerification", + "startupVerificationCooldownHours", + "mediaMaxMb", + "autoJoin", + "autoJoinAllowlist", + "dm", + "groups", + "rooms", + "actions", +]); + export function shouldMoveSingleAccountChannelKey(params: { channelKey: string; key: string; @@ -348,6 +413,76 @@ export function shouldMoveSingleAccountChannelKey(params: { return SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL[params.channelKey]?.has(params.key) ?? false; } +export function resolveSingleAccountKeysToMove(params: { + channelKey: string; + channel: Record; +}): string[] { + const hasNamedAccounts = + Object.keys((params.channel.accounts as Record) ?? {}).filter(Boolean).length > + 0; + return Object.entries(params.channel) + .filter(([key, value]) => { + if (key === "accounts" || key === "enabled" || value === undefined) { + return false; + } + if (!shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key })) { + return false; + } + if ( + params.channelKey === "matrix" && + hasNamedAccounts && + !MATRIX_NAMED_ACCOUNT_PROMOTION_KEYS.has(key) + ) { + return false; + } + return true; + }) + .map(([key]) => key); +} + +export function resolveSingleAccountPromotionTarget(params: { + channelKey: string; + channel: ChannelSectionBase; +}): string { + if (params.channelKey !== "matrix") { + return DEFAULT_ACCOUNT_ID; + } + const accounts = params.channel.accounts ?? {}; + const normalizedDefaultAccount = + typeof params.channel.defaultAccount === "string" && params.channel.defaultAccount.trim() + ? normalizeAccountId(params.channel.defaultAccount) + : undefined; + if (normalizedDefaultAccount) { + if (normalizedDefaultAccount !== DEFAULT_ACCOUNT_ID) { + const matchedAccountId = Object.entries(accounts).find( + ([accountId, value]) => + accountId && + value && + typeof value === "object" && + normalizeAccountId(accountId) === normalizedDefaultAccount, + )?.[0]; + if (matchedAccountId) { + return matchedAccountId; + } + } + return DEFAULT_ACCOUNT_ID; + } + const namedAccounts = Object.entries(accounts).filter( + ([accountId, value]) => accountId && typeof value === "object" && value, + ); + if (namedAccounts.length === 1) { + return namedAccounts[0][0]; + } + if ( + namedAccounts.length > 1 && + accounts[DEFAULT_ACCOUNT_ID] && + typeof accounts[DEFAULT_ACCOUNT_ID] === "object" + ) { + return DEFAULT_ACCOUNT_ID; + } + return DEFAULT_ACCOUNT_ID; +} + function cloneIfObject(value: T): T { if (value && typeof value === "object") { return structuredClone(value); @@ -372,18 +507,50 @@ export function moveSingleAccountChannelSectionToDefaultAccount(params: { const accounts = base.accounts ?? {}; if (Object.keys(accounts).length > 0) { - return params.cfg; - } + if (params.channelKey !== "matrix") { + return params.cfg; + } + const keysToMove = resolveSingleAccountKeysToMove({ + channelKey: params.channelKey, + channel: base, + }); + if (keysToMove.length === 0) { + return params.cfg; + } - const keysToMove = Object.entries(base) - .filter( - ([key, value]) => - key !== "accounts" && - key !== "enabled" && - value !== undefined && - shouldMoveSingleAccountChannelKey({ channelKey: params.channelKey, key }), - ) - .map(([key]) => key); + const targetAccountId = resolveSingleAccountPromotionTarget({ + channelKey: params.channelKey, + channel: base, + }); + const defaultAccount: Record = { + ...accounts[targetAccountId], + }; + for (const key of keysToMove) { + const value = base[key]; + defaultAccount[key] = cloneIfObject(value); + } + const nextChannel: ChannelSectionRecord = { ...base }; + for (const key of keysToMove) { + delete nextChannel[key]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [params.channelKey]: { + ...nextChannel, + accounts: { + ...accounts, + [targetAccountId]: defaultAccount, + }, + }, + }, + } as OpenClawConfig; + } + const keysToMove = resolveSingleAccountKeysToMove({ + channelKey: params.channelKey, + channel: base, + }); const defaultAccount: Record = {}; for (const key of keysToMove) { const value = base[key]; diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index 7dec2ea87a4..f5939757626 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -13,6 +13,7 @@ export type SetupChannelsOptions = { allowDisable?: boolean; allowSignalInstall?: boolean; onSelection?: (selection: ChannelId[]) => void; + onPostWriteHook?: (hook: ChannelOnboardingPostWriteHook) => void; accountIds?: Partial>; onAccountId?: (channel: ChannelId, accountId: string) => void; onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; @@ -64,6 +65,19 @@ export type ChannelSetupConfigureContext = { forceAllowFrom: boolean; }; +export type ChannelOnboardingPostWriteContext = { + previousCfg: OpenClawConfig; + cfg: OpenClawConfig; + accountId: string; + runtime: RuntimeEnv; +}; + +export type ChannelOnboardingPostWriteHook = { + channel: ChannelId; + accountId: string; + run: (ctx: { cfg: OpenClawConfig; runtime: RuntimeEnv }) => Promise | void; +}; + export type ChannelSetupResult = { cfg: OpenClawConfig; accountId?: string; @@ -81,8 +95,12 @@ export type ChannelSetupDmPolicy = { channel: ChannelId; policyKey: string; allowFromKey: string; - getCurrent: (cfg: OpenClawConfig) => DmPolicy; - setPolicy: (cfg: OpenClawConfig, policy: DmPolicy) => OpenClawConfig; + resolveConfigKeys?: ( + cfg: OpenClawConfig, + accountId?: string, + ) => { policyKey: string; allowFromKey: string }; + getCurrent: (cfg: OpenClawConfig, accountId?: string) => DmPolicy; + setPolicy: (cfg: OpenClawConfig, policy: DmPolicy, accountId?: string) => OpenClawConfig; promptAllowFrom?: (params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -100,6 +118,7 @@ export type ChannelSetupWizardAdapter = { configureWhenConfigured?: ( ctx: ChannelSetupInteractiveContext, ) => Promise; + afterConfigWritten?: (ctx: ChannelOnboardingPostWriteContext) => Promise | void; dmPolicy?: ChannelSetupDmPolicy; onAccountRecorded?: (accountId: string, options?: SetupChannelsOptions) => void; disable?: (cfg: OpenClawConfig) => OpenClawConfig; diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 7274d612c7c..14a7ab10b8e 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -74,6 +74,13 @@ export type ChannelSetupAdapter = { accountId: string; input: ChannelSetupInput; }) => OpenClawConfig; + afterAccountConfigWritten?: (params: { + previousCfg: OpenClawConfig; + cfg: OpenClawConfig; + accountId: string; + input: ChannelSetupInput; + runtime: RuntimeEnv; + }) => Promise | void; validateInput?: (params: { cfg: OpenClawConfig; accountId: string; @@ -170,10 +177,6 @@ export type ChannelOutboundAdapter = { ) => Promise; sendText?: (ctx: ChannelOutboundContext) => Promise; sendMedia?: (ctx: ChannelOutboundContext) => Promise; - /** - * Shared outbound poll adapter for channels that fit the common poll model. - * Channels with extra poll semantics should prefer `actions.handleAction("poll")`. - */ sendPoll?: (ctx: ChannelPollContext) => Promise; }; @@ -334,6 +337,7 @@ export type ChannelPairingAdapter = { notifyApproval?: (params: { cfg: OpenClawConfig; id: string; + accountId?: string; runtime?: RuntimeEnv; }) => Promise; }; diff --git a/src/commands/agents.bind.matrix.integration.test.ts b/src/commands/agents.bind.matrix.integration.test.ts new file mode 100644 index 00000000000..416d9f88250 --- /dev/null +++ b/src/commands/agents.bind.matrix.integration.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { matrixPlugin } from "../../extensions/matrix/src/channel.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { agentsBindCommand } from "./agents.js"; +import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: readConfigFileSnapshotMock, + writeConfigFile: writeConfigFileMock, +})); + +describe("agents bind matrix integration", () => { + const runtime = createTestRuntime(); + + beforeEach(() => { + readConfigFileSnapshotMock.mockClear(); + writeConfigFileMock.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), + ); + }); + + afterEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + + it("uses matrix plugin binding resolver when accountId is omitted", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [ + { type: "route", agentId: "main", match: { channel: "matrix", accountId: "main" } }, + ], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index 455ff235be6..67559604100 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -1,4 +1,4 @@ -import { matrixPlugin } from "../../extensions/matrix/index.js"; +import { matrixPlugin, setMatrixRuntime } from "../../extensions/matrix/index.js"; import { msteamsPlugin } from "../../extensions/msteams/index.js"; import { nostrPlugin } from "../../extensions/nostr/index.js"; import { tlonPlugin } from "../../extensions/tlon/index.js"; @@ -12,11 +12,16 @@ import type { ChannelChoice } from "./onboard-types.js"; type ChannelSetupWizardAdapterPatch = Partial< Pick< ChannelSetupWizardAdapter, - "configure" | "configureInteractive" | "configureWhenConfigured" | "getStatus" + | "afterConfigWritten" + | "configure" + | "configureInteractive" + | "configureWhenConfigured" + | "getStatus" > >; type PatchedSetupAdapterFields = { + afterConfigWritten?: ChannelSetupWizardAdapter["afterConfigWritten"]; configure?: ChannelSetupWizardAdapter["configure"]; configureInteractive?: ChannelSetupWizardAdapter["configureInteractive"]; configureWhenConfigured?: ChannelSetupWizardAdapter["configureWhenConfigured"]; @@ -24,6 +29,11 @@ type PatchedSetupAdapterFields = { }; export function setDefaultChannelPluginRegistryForTests(): void { + setMatrixRuntime({ + state: { + resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), + }, + } as Parameters[0]); const channels = [ ...bundledChannelPlugins, matrixPlugin, @@ -53,6 +63,10 @@ export function patchChannelSetupWizardAdapter( previous.getStatus = adapter.getStatus; adapter.getStatus = patch.getStatus ?? adapter.getStatus; } + if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) { + previous.afterConfigWritten = adapter.afterConfigWritten; + adapter.afterConfigWritten = patch.afterConfigWritten; + } if (Object.prototype.hasOwnProperty.call(patch, "configure")) { previous.configure = adapter.configure; adapter.configure = patch.configure ?? adapter.configure; @@ -70,6 +84,9 @@ export function patchChannelSetupWizardAdapter( if (Object.prototype.hasOwnProperty.call(patch, "getStatus")) { adapter.getStatus = previous.getStatus!; } + if (Object.prototype.hasOwnProperty.call(patch, "afterConfigWritten")) { + adapter.afterConfigWritten = previous.afterConfigWritten; + } if (Object.prototype.hasOwnProperty.call(patch, "configure")) { adapter.configure = previous.configure!; } @@ -81,3 +98,5 @@ export function patchChannelSetupWizardAdapter( } }; } + +export const patchChannelOnboardingAdapter = patchChannelSetupWizardAdapter; diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 4e449df5099..99fa5bb7ce7 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { @@ -153,11 +154,9 @@ describe("channelsAddCommand", () => { })), }, }; - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) - .mockReturnValueOnce(createTestRegistry()) - .mockReturnValueOnce( - createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), - ); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]), + ); await channelsAddCommand( { @@ -294,35 +293,33 @@ describe("channelsAddCommand", () => { installed: true, pluginId: "@vendor/teams-runtime", })); - vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) - .mockReturnValueOnce(createTestRegistry()) - .mockReturnValueOnce( - createTestRegistry([ - { - pluginId: "@vendor/teams-runtime", - plugin: { - ...createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", - docsPath: "/channels/msteams", - }), - setup: { - applyAccountConfig: vi.fn(({ cfg, input }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - enabled: true, - tenantId: input.token, - }, + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry([ + { + pluginId: "@vendor/teams-runtime", + plugin: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + setup: { + applyAccountConfig: vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + enabled: true, + tenantId: input.token, }, - })), - }, + }, + })), }, - source: "test", }, - ]), - ); + source: "test", + }, + ]), + ); await channelsAddCommand( { @@ -343,4 +340,106 @@ describe("channelsAddCommand", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); }); + + it("runs post-setup hooks after writing config", async () => { + const afterAccountConfigWritten = vi.fn().mockResolvedValue(undefined); + const plugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "signal", + label: "Signal", + }), + setup: { + applyAccountConfig: ({ cfg, accountId, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + signal: { + enabled: true, + accounts: { + [accountId]: { + signalNumber: input.signalNumber, + }, + }, + }, + }, + }), + afterAccountConfigWritten, + }, + } as ChannelPlugin; + setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }])); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand( + { channel: "signal", account: "ops", signalNumber: "+15550001" }, + runtime, + { hasFlags: true }, + ); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + expect(afterAccountConfigWritten).toHaveBeenCalledTimes(1); + expect(configMocks.writeConfigFile.mock.invocationCallOrder[0]).toBeLessThan( + afterAccountConfigWritten.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(afterAccountConfigWritten).toHaveBeenCalledWith({ + previousCfg: baseConfigSnapshot.config, + cfg: expect.objectContaining({ + channels: { + signal: { + enabled: true, + accounts: { + ops: { + signalNumber: "+15550001", + }, + }, + }, + }, + }), + accountId: "ops", + input: expect.objectContaining({ + signalNumber: "+15550001", + }), + runtime, + }); + }); + + it("keeps the saved config when a post-setup hook fails", async () => { + const afterAccountConfigWritten = vi.fn().mockRejectedValue(new Error("hook failed")); + const plugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "signal", + label: "Signal", + }), + setup: { + applyAccountConfig: ({ cfg, accountId, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + signal: { + enabled: true, + accounts: { + [accountId]: { + signalNumber: input.signalNumber, + }, + }, + }, + }, + }), + afterAccountConfigWritten, + }, + } as ChannelPlugin; + setActivePluginRegistry(createTestRegistry([{ pluginId: "signal", plugin, source: "test" }])); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand( + { channel: "signal", account: "ops", signalNumber: "+15550001" }, + runtime, + { hasFlags: true }, + ); + + expect(configMocks.writeConfigFile).toHaveBeenCalledTimes(1); + expect(runtime.exit).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + 'Channel signal post-setup warning for "ops": hook failed', + ); + }); }); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index abf9b360285..03aa841edd5 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,18 +1,20 @@ -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupInput } from "../../channels/plugins/types.js"; -import { writeConfigFile } from "../../config/config.js"; +import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js"; +import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; import { applyAgentBindings, describeBinding } from "../agents.bindings.js"; +import { isCatalogChannelInstalled } from "../channel-setup/discovery.js"; import { - resolveCatalogChannelEntry, - resolveInstallableChannelPlugin, -} from "../channel-setup/channel-plugin-resolution.js"; + createChannelOnboardingPostWriteHookCollector, + runCollectedChannelOnboardingPostWriteHooks, +} from "../onboard-channels.js"; import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; @@ -24,6 +26,21 @@ export type ChannelsAddOptions = { groupChannels?: string; dmAllowlist?: string; } & Omit; + +function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) { + const trimmed = raw.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; + return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + if (entry.id.toLowerCase() === trimmed) { + return true; + } + return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed); + }); +} + export async function channelsAddCommand( opts: ChannelsAddOptions, runtime: RuntimeEnv = defaultRuntime, @@ -42,6 +59,7 @@ export async function channelsAddCommand( import("../onboard-channels.js"), ]); const prompter = createClackPrompter(); + const postWriteHooks = createChannelOnboardingPostWriteHookCollector(); let selection: ChannelChoice[] = []; const accountIds: Partial> = {}; const resolvedPlugins = new Map(); @@ -49,6 +67,9 @@ export async function channelsAddCommand( let nextConfig = await setupChannels(cfg, runtime, prompter, { allowDisable: false, allowSignalInstall: true, + onPostWriteHook: (hook) => { + postWriteHooks.collect(hook); + }, promptAccountIds: true, onSelection: (value) => { selection = value; @@ -157,6 +178,11 @@ export async function channelsAddCommand( } await writeConfigFile(nextConfig); + await runCollectedChannelOnboardingPostWriteHooks({ + hooks: postWriteHooks.drain(), + cfg: nextConfig, + runtime, + }); await prompter.outro("Channels updated."); return; } @@ -164,17 +190,62 @@ export async function channelsAddCommand( const rawChannel = String(opts.channel ?? ""); let channel = normalizeChannelId(rawChannel); let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig); - const resolvedPluginState = await resolveInstallableChannelPlugin({ - cfg: nextConfig, - runtime, - rawChannel, - allowInstall: true, - prompter: createClackPrompter(), - supports: (plugin) => Boolean(plugin.setup?.applyAccountConfig), - }); - nextConfig = resolvedPluginState.cfg; - channel = resolvedPluginState.channelId ?? channel; - catalogEntry = resolvedPluginState.catalogEntry ?? catalogEntry; + const resolveWorkspaceDir = () => + resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + // May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import) + const loadScopedPlugin = async ( + channelId: ChannelId, + pluginId?: string, + ): Promise => { + const existing = getChannelPlugin(channelId); + if (existing) { + return existing; + } + const { loadChannelSetupPluginRegistrySnapshotForChannel } = + await import("../channel-setup/plugin-install.js"); + const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({ + cfg: nextConfig, + runtime, + channel: channelId, + ...(pluginId ? { pluginId } : {}), + workspaceDir: resolveWorkspaceDir(), + }); + return ( + snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? + snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin + ); + }; + + if (!channel && catalogEntry) { + const workspaceDir = resolveWorkspaceDir(); + if ( + !isCatalogChannelInstalled({ + cfg: nextConfig, + entry: catalogEntry, + workspaceDir, + }) + ) { + const { ensureChannelSetupPluginInstalled } = + await import("../channel-setup/plugin-install.js"); + const prompter = createClackPrompter(); + const result = await ensureChannelSetupPluginInstalled({ + cfg: nextConfig, + entry: catalogEntry, + prompter, + runtime, + workspaceDir, + }); + nextConfig = result.cfg; + if (!result.installed) { + return; + } + catalogEntry = { + ...catalogEntry, + ...(result.pluginId ? { pluginId: result.pluginId } : {}), + }; + } + channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); + } if (!channel) { const hint = catalogEntry @@ -185,7 +256,7 @@ export async function channelsAddCommand( return; } - const plugin = resolvedPluginState.plugin ?? (channel ? getChannelPlugin(channel) : undefined); + const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId); if (!plugin?.setup?.applyAccountConfig) { runtime.error(`Channel ${channel} does not support add.`); runtime.exit(1); @@ -279,4 +350,24 @@ export async function channelsAddCommand( await writeConfigFile(nextConfig); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); + if (plugin.setup.afterAccountConfigWritten) { + await runCollectedChannelOnboardingPostWriteHooks({ + hooks: [ + { + channel, + accountId, + run: async ({ cfg: writtenCfg, runtime: hookRuntime }) => + await plugin.setup.afterAccountConfigWritten?.({ + previousCfg: cfg, + cfg: writtenCfg, + accountId, + input, + runtime: hookRuntime, + }), + }, + ], + cfg: nextConfig, + runtime, + }); + } } diff --git a/src/commands/onboard-channels.post-write.test.ts b/src/commands/onboard-channels.post-write.test.ts new file mode 100644 index 00000000000..f96dd276e22 --- /dev/null +++ b/src/commands/onboard-channels.post-write.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { + patchChannelOnboardingAdapter, + setDefaultChannelPluginRegistryForTests, +} from "./channel-test-helpers.js"; +import { + createChannelOnboardingPostWriteHookCollector, + runCollectedChannelOnboardingPostWriteHooks, + setupChannels, +} from "./onboard-channels.js"; +import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return createWizardPrompter( + { + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }, + { defaultSelect: "__done__" }, + ); +} + +function createQuickstartTelegramSelect() { + return vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "telegram"; + } + return "__done__"; + }); +} + +function createUnexpectedQuickstartPrompter(select: WizardPrompter["select"]) { + return createPrompter({ + select, + multiselect: vi.fn(async () => { + throw new Error("unexpected multiselect"); + }), + text: vi.fn(async ({ message }: { message: string }) => { + throw new Error(`unexpected text prompt: ${message}`); + }) as unknown as WizardPrompter["text"], + }); +} + +describe("setupChannels post-write hooks", () => { + beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + + it("collects onboarding post-write hooks and runs them against the final config", async () => { + const select = createQuickstartTelegramSelect(); + const afterConfigWritten = vi.fn(async () => {}); + const configureInteractive = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + cfg: { + ...cfg, + channels: { + ...cfg.channels, + telegram: { ...cfg.channels?.telegram, botToken: "new-token" }, + }, + } as OpenClawConfig, + accountId: "acct-1", + })); + const restore = patchChannelOnboardingAdapter("telegram", { + configureInteractive, + afterConfigWritten, + getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ + channel: "telegram", + configured: Boolean(cfg.channels?.telegram?.botToken), + statusLines: [], + })), + }); + const prompter = createUnexpectedQuickstartPrompter( + select as unknown as WizardPrompter["select"], + ); + const collector = createChannelOnboardingPostWriteHookCollector(); + const runtime = createExitThrowingRuntime(); + + try { + const cfg = await setupChannels({} as OpenClawConfig, runtime, prompter, { + quickstartDefaults: true, + skipConfirm: true, + onPostWriteHook: (hook) => { + collector.collect(hook); + }, + }); + + expect(afterConfigWritten).not.toHaveBeenCalled(); + + await runCollectedChannelOnboardingPostWriteHooks({ + hooks: collector.drain(), + cfg, + runtime, + }); + + expect(afterConfigWritten).toHaveBeenCalledWith({ + previousCfg: {} as OpenClawConfig, + cfg, + accountId: "acct-1", + runtime, + }); + } finally { + restore(); + } + }); + + it("logs onboarding post-write hook failures without aborting", async () => { + const runtime = createExitThrowingRuntime(); + + await runCollectedChannelOnboardingPostWriteHooks({ + hooks: [ + { + channel: "telegram", + accountId: "acct-1", + run: async () => { + throw new Error("hook failed"); + }, + }, + ], + cfg: {} as OpenClawConfig, + runtime, + }); + + expect(runtime.error).toHaveBeenCalledWith( + 'Channel telegram post-setup warning for "acct-1": hook failed', + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 569e4cd4a44..514b1a8fa5e 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -32,6 +32,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupResult, ChannelSetupStatus, + ChannelOnboardingPostWriteHook, SetupChannelsOptions, } from "./channel-setup/types.js"; import type { ChannelChoice } from "./onboard-types.js"; @@ -46,6 +47,37 @@ type ChannelStatusSummary = { statusLines: string[]; }; +export function createChannelOnboardingPostWriteHookCollector() { + const hooks = new Map(); + return { + collect(hook: ChannelOnboardingPostWriteHook) { + hooks.set(`${hook.channel}:${hook.accountId}`, hook); + }, + drain(): ChannelOnboardingPostWriteHook[] { + const next = [...hooks.values()]; + hooks.clear(); + return next; + }, + }; +} + +export async function runCollectedChannelOnboardingPostWriteHooks(params: { + hooks: ChannelOnboardingPostWriteHook[]; + cfg: OpenClawConfig; + runtime: RuntimeEnv; +}): Promise { + for (const hook of params.hooks) { + try { + await hook.run({ cfg: params.cfg, runtime: params.runtime }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + params.runtime.error( + `Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`, + ); + } + } +} + function formatAccountLabel(accountId: string): string { return accountId === DEFAULT_ACCOUNT_ID ? "default (primary)" : accountId; } @@ -292,12 +324,17 @@ async function maybeConfigureDmPolicies(params: { let cfg = params.cfg; const selectPolicy = async (policy: ChannelSetupDmPolicy) => { + const accountId = accountIdsByChannel?.get(policy.channel); + const { policyKey, allowFromKey } = policy.resolveConfigKeys?.(cfg, accountId) ?? { + policyKey: policy.policyKey, + allowFromKey: policy.allowFromKey, + }; await prompter.note( [ "Default: pairing (unknown DMs get a pairing code).", `Approve: ${formatCliCommand(`openclaw pairing approve ${policy.channel} `)}`, - `Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`, - `Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`, + `Allowlist DMs: ${policyKey}="allowlist" + ${allowFromKey} entries.`, + `Public DMs: ${policyKey}="open" + ${allowFromKey} includes "*".`, "Multi-user DMs: run: " + formatCliCommand('openclaw config set session.dmScope "per-channel-peer"') + ' (or "per-account-channel-peer" for multi-account channels) to isolate sessions.', @@ -305,28 +342,31 @@ async function maybeConfigureDmPolicies(params: { ].join("\n"), `${policy.label} DM access`, ); - return (await prompter.select({ - message: `${policy.label} DM policy`, - options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist (specific users only)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore DMs)" }, - ], - })) as DmPolicy; + return { + accountId, + nextPolicy: (await prompter.select({ + message: `${policy.label} DM policy`, + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist (specific users only)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore DMs)" }, + ], + })) as DmPolicy, + }; }; for (const policy of dmPolicies) { - const current = policy.getCurrent(cfg); - const nextPolicy = await selectPolicy(policy); + const { accountId, nextPolicy } = await selectPolicy(policy); + const current = policy.getCurrent(cfg, accountId); if (nextPolicy !== current) { - cfg = policy.setPolicy(cfg, nextPolicy); + cfg = policy.setPolicy(cfg, nextPolicy, accountId); } if (nextPolicy === "allowlist" && policy.promptAllowFrom) { cfg = await policy.promptAllowFrom({ cfg, prompter, - accountId: accountIdsByChannel?.get(policy.channel), + accountId, }); } } @@ -600,9 +640,24 @@ export async function setupChannels( }; const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { + const previousCfg = next; next = result.cfg; + const adapter = getVisibleSetupFlowAdapter(channel); if (result.accountId) { recordAccount(channel, result.accountId); + if (adapter?.afterConfigWritten) { + options?.onPostWriteHook?.({ + channel, + accountId: result.accountId, + run: async ({ cfg, runtime }) => + await adapter.afterConfigWritten?.({ + previousCfg, + cfg, + accountId: result.accountId!, + runtime, + }), + }); + } } addSelection(channel); await refreshStatus(channel); diff --git a/src/gateway/server-startup-matrix-migration.test.ts b/src/gateway/server-startup-matrix-migration.test.ts new file mode 100644 index 00000000000..95e72bf39dc --- /dev/null +++ b/src/gateway/server-startup-matrix-migration.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js"; + +describe("runStartupMatrixMigration", () => { + it("creates a snapshot before actionable startup migration", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}'); + const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({ + created: true, + archivePath: "/tmp/snapshot.tar.gz", + markerPath: "/tmp/migration-snapshot.json", + })); + const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({ + migrated: true, + changes: [], + warnings: [], + })); + const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => ({ + migrated: false, + changes: [], + warnings: [], + })); + + await runStartupMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never, + env: process.env, + deps: { + maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock, + autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock, + autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock, + }, + log: {}, + }); + + expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ trigger: "gateway-startup" }), + ); + expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce(); + expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce(); + }); + }); + + it("skips snapshot creation when startup only has warning-only migration state", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}'); + const maybeCreateMatrixMigrationSnapshotMock = vi.fn(); + const autoMigrateLegacyMatrixStateMock = vi.fn(); + const autoPrepareLegacyMatrixCryptoMock = vi.fn(); + const info = vi.fn(); + + await runStartupMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + }, + }, + } as never, + env: process.env, + deps: { + maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock as never, + autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never, + autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never, + }, + log: { info }, + }); + + expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled(); + expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled(); + expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalledWith( + "matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet", + ); + }); + }); + + it("skips startup migration when snapshot creation fails", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}'); + const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => { + throw new Error("backup failed"); + }); + const autoMigrateLegacyMatrixStateMock = vi.fn(); + const autoPrepareLegacyMatrixCryptoMock = vi.fn(); + const warn = vi.fn(); + + await runStartupMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never, + env: process.env, + deps: { + maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock, + autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never, + autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never, + }, + log: { warn }, + }); + + expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled(); + expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + "gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: Error: backup failed", + ); + }); + }); + + it("downgrades migration step failures to warnings so startup can continue", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}'); + const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => ({ + created: true, + archivePath: "/tmp/snapshot.tar.gz", + markerPath: "/tmp/migration-snapshot.json", + })); + const autoMigrateLegacyMatrixStateMock = vi.fn(async () => ({ + migrated: true, + changes: [], + warnings: [], + })); + const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => { + throw new Error("disk full"); + }); + const warn = vi.fn(); + + await expect( + runStartupMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never, + env: process.env, + deps: { + maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock, + autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock, + autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock, + }, + log: { warn }, + }), + ).resolves.toBeUndefined(); + + expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledOnce(); + expect(autoMigrateLegacyMatrixStateMock).toHaveBeenCalledOnce(); + expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + "gateway: legacy Matrix encrypted-state preparation failed during Matrix migration; continuing startup: Error: disk full", + ); + }); + }); +}); diff --git a/src/gateway/server-startup-matrix-migration.ts b/src/gateway/server-startup-matrix-migration.ts new file mode 100644 index 00000000000..64a5f4e0721 --- /dev/null +++ b/src/gateway/server-startup-matrix-migration.ts @@ -0,0 +1,92 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js"; +import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js"; +import { + hasActionableMatrixMigration, + hasPendingMatrixMigration, + maybeCreateMatrixMigrationSnapshot, +} from "../infra/matrix-migration-snapshot.js"; + +type MatrixMigrationLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +async function runBestEffortMatrixMigrationStep(params: { + label: string; + log: MatrixMigrationLogger; + run: () => Promise; +}): Promise { + try { + await params.run(); + } catch (err) { + params.log.warn?.( + `gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`, + ); + } +} + +export async function runStartupMatrixMigration(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log: MatrixMigrationLogger; + deps?: { + maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot; + autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState; + autoPrepareLegacyMatrixCrypto?: typeof autoPrepareLegacyMatrixCrypto; + }; +}): Promise { + const env = params.env ?? process.env; + const createSnapshot = + params.deps?.maybeCreateMatrixMigrationSnapshot ?? maybeCreateMatrixMigrationSnapshot; + const migrateLegacyState = + params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState; + const prepareLegacyCrypto = + params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto; + const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env }); + const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env }); + + if (!pending) { + return; + } + if (!actionable) { + params.log.info?.( + "matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet", + ); + return; + } + + try { + await createSnapshot({ + trigger: "gateway-startup", + env, + log: params.log, + }); + } catch (err) { + params.log.warn?.( + `gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`, + ); + return; + } + + await runBestEffortMatrixMigrationStep({ + label: "legacy Matrix state migration", + log: params.log, + run: () => + migrateLegacyState({ + cfg: params.cfg, + env, + log: params.log, + }), + }); + await runBestEffortMatrixMigrationStep({ + label: "legacy Matrix encrypted-state preparation", + log: params.log, + run: () => + prepareLegacyCrypto({ + cfg: params.cfg, + env, + log: params.log, + }), + }); +} diff --git a/src/infra/matrix-account-selection.test.ts b/src/infra/matrix-account-selection.test.ts new file mode 100644 index 00000000000..d7f13a7fb9d --- /dev/null +++ b/src/infra/matrix-account-selection.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + findMatrixAccountEntry, + getMatrixScopedEnvVarNames, + requiresExplicitMatrixDefaultAccount, + resolveConfiguredMatrixAccountIds, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../extensions/matrix/runtime-api.js"; +import type { OpenClawConfig } from "../config/config.js"; + +describe("matrix account selection", () => { + it("resolves configured account ids from non-canonical account keys", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + "Team Ops": { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(resolveConfiguredMatrixAccountIds(cfg)).toEqual(["team-ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); + }); + + it("matches the default account against normalized Matrix account keys", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + defaultAccount: "Team Ops", + accounts: { + "Ops Bot": { homeserver: "https://matrix.example.org" }, + "Team Ops": { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(resolveMatrixDefaultOrOnlyAccountId(cfg)).toBe("team-ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(false); + }); + + it("requires an explicit default when multiple Matrix accounts exist without one", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { homeserver: "https://matrix.example.org" }, + alerts: { homeserver: "https://matrix.example.org" }, + }, + }, + }, + }; + + expect(requiresExplicitMatrixDefaultAccount(cfg)).toBe(true); + }); + + it("finds the raw Matrix account entry by normalized account id", () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + "Team Ops": { + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + }, + }, + }, + }, + }; + + expect(findMatrixAccountEntry(cfg, "team-ops")).toEqual({ + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + }); + }); + + it("discovers env-backed named Matrix accounts during enumeration", () => { + const keys = getMatrixScopedEnvVarNames("team-ops"); + const cfg: OpenClawConfig = { + channels: { + matrix: {}, + }, + }; + const env = { + [keys.homeserver]: "https://matrix.example.org", + [keys.accessToken]: "secret", + } satisfies NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["team-ops"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("team-ops"); + expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false); + }); + + it("treats mixed default and named env-backed Matrix accounts as multi-account", () => { + const keys = getMatrixScopedEnvVarNames("team-ops"); + const cfg: OpenClawConfig = { + channels: { + matrix: {}, + }, + }; + const env = { + MATRIX_HOMESERVER: "https://matrix.example.org", + MATRIX_ACCESS_TOKEN: "default-secret", + [keys.homeserver]: "https://matrix.example.org", + [keys.accessToken]: "team-secret", + } satisfies NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "team-ops"]); + expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true); + }); + + it("discovers default Matrix accounts backed only by global env vars", () => { + const cfg: OpenClawConfig = {}; + const env = { + MATRIX_HOMESERVER: "https://matrix.example.org", + MATRIX_ACCESS_TOKEN: "default-secret", + } satisfies NodeJS.ProcessEnv; + + expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default"]); + expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default"); + }); +}); diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts new file mode 100644 index 00000000000..08501260943 --- /dev/null +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -0,0 +1,448 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; +import { MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE } from "./matrix-plugin-helper.js"; + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf8"); +} + +function writeMatrixPluginFixture(rootDir: string): void { + fs.mkdirSync(rootDir, { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "matrix", + configSchema: { + type: "object", + additionalProperties: false, + }, + }), + "utf8", + ); + fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "legacy-crypto-inspector.js"), + [ + "export async function inspectLegacyMatrixCryptoStore() {", + ' return { deviceId: "FIXTURE", roomKeyCounts: { total: 1, backedUp: 1 }, backupVersion: "1", decryptionKeyBase64: null };', + "}", + ].join("\n"), + "utf8", + ); +} + +const matrixHelperEnv = { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home: string) => path.join(home, "bundled"), +}; + +describe("matrix legacy encrypted-state migration", () => { + it("extracts a saved backup key into the new recovery-key path", async () => { + await withTempHome( + async (home) => { + writeMatrixPluginFixture(path.join(home, "bundled", "matrix")); + const stateDir = path.join(home, ".openclaw"); + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}'); + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.warnings).toEqual([]); + expect(detection.plans).toHaveLength(1); + + const inspectLegacyStore = vi.fn(async () => ({ + deviceId: "DEVICE123", + roomKeyCounts: { total: 12, backedUp: 12 }, + backupVersion: "1", + decryptionKeyBase64: "YWJjZA==", + })); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { inspectLegacyStore }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(inspectLegacyStore).toHaveBeenCalledOnce(); + + const recovery = JSON.parse( + fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), + ) as { + privateKeyBase64: string; + }; + expect(recovery.privateKeyBase64).toBe("YWJjZA=="); + + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + restoreStatus: string; + decryptionKeyImported: boolean; + }; + expect(state.restoreStatus).toBe("pending"); + expect(state.decryptionKeyImported).toBe(true); + }, + { env: matrixHelperEnv }, + ); + }); + + it("warns when legacy local-only room keys cannot be recovered automatically", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}'); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: "DEVICE123", + roomKeyCounts: { total: 15, backedUp: 10 }, + backupVersion: null, + decryptionKeyBase64: null, + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toContain( + 'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.', + ); + expect(result.warnings).toContain( + 'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.', + ); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + restoreStatus: string; + }; + expect(state.restoreStatus).toBe("manual-action-required"); + }); + }); + + it("warns instead of throwing when recovery-key persistence fails", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + writeFile(path.join(rootDir, "crypto", "bot-sdk.json"), '{"deviceId":"DEVICE123"}'); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: "DEVICE123", + roomKeyCounts: { total: 12, backedUp: 12 }, + backupVersion: "1", + decryptionKeyBase64: "YWJjZA==", + }), + writeJsonFileAtomically: async (filePath) => { + if (filePath.endsWith("recovery-key.json")) { + throw new Error("disk full"); + } + writeFile(filePath, JSON.stringify({ ok: true }, null, 2)); + }, + }, + }); + + expect(result.migrated).toBe(false); + expect(result.warnings).toContain( + `Failed writing Matrix recovery key for account "default" (${path.join(rootDir, "recovery-key.json")}): Error: disk full`, + ); + expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false); + expect(fs.existsSync(path.join(rootDir, "legacy-crypto-migration.json"))).toBe(false); + }); + }); + + it("prepares flat legacy crypto for the only configured non-default Matrix account", async () => { + await withTempHome( + async (home) => { + writeMatrixPluginFixture(path.join(home, "bundled", "matrix")); + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICEOPS" }), + ); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + deviceId: "DEVICEOPS", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + accountId: "ops", + }); + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.warnings).toEqual([]); + expect(detection.plans).toHaveLength(1); + expect(detection.plans[0]?.accountId).toBe("ops"); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: "DEVICEOPS", + roomKeyCounts: { total: 6, backedUp: 6 }, + backupVersion: "21868", + decryptionKeyBase64: "YWJjZA==", + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + const recovery = JSON.parse( + fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), + ) as { + privateKeyBase64: string; + }; + expect(recovery.privateKeyBase64).toBe("YWJjZA=="); + const state = JSON.parse( + fs.readFileSync(path.join(rootDir, "legacy-crypto-migration.json"), "utf8"), + ) as { + accountId: string; + }; + expect(state.accountId).toBe("ops"); + }, + { env: matrixHelperEnv }, + ); + }); + + it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => { + await withTempHome( + async (home) => { + writeMatrixPluginFixture(path.join(home, "bundled", "matrix")); + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICEOPS" }), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }; + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops-env", + accountId: "ops", + }); + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.warnings).toEqual([]); + expect(detection.plans).toHaveLength(1); + expect(detection.plans[0]?.accountId).toBe("ops"); + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + deps: { + inspectLegacyStore: async () => ({ + deviceId: "DEVICEOPS", + roomKeyCounts: { total: 4, backedUp: 4 }, + backupVersion: "9001", + decryptionKeyBase64: "YWJjZA==", + }), + }, + }); + + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + const recovery = JSON.parse( + fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"), + ) as { + privateKeyBase64: string; + }; + expect(recovery.privateKeyBase64).toBe("YWJjZA=="); + }, + { + env: { + ...matrixHelperEnv, + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_USER_ID: "@ops-bot:example.org", + MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", + }, + }, + ); + }); + + it("requires channels.matrix.defaultAccount before preparing flat legacy crypto for one of multiple accounts", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICEOPS" }), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + }, + alerts: { + homeserver: "https://matrix.example.org", + userId: "@alerts-bot:example.org", + accessToken: "tok-alerts", + }, + }, + }, + }, + }; + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.plans).toHaveLength(0); + expect(detection.warnings).toContain( + "Legacy Matrix encrypted state detected at " + + path.join(stateDir, "matrix", "crypto") + + ', but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.', + ); + }); + }); + + it("warns instead of throwing when a legacy crypto path is a file", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory"); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + + const detection = detectLegacyMatrixCrypto({ cfg, env: process.env }); + expect(detection.plans).toHaveLength(0); + expect(detection.warnings).toContain( + `Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`, + ); + }); + }); + + it("reports a missing matrix plugin helper once when encrypted-state migration cannot run", async () => { + await withTempHome( + async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "matrix", "crypto", "bot-sdk.json"), + '{"deviceId":"DEVICE123"}', + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + + const result = await autoPrepareLegacyMatrixCrypto({ + cfg, + env: process.env, + }); + + expect(result.migrated).toBe(false); + expect( + result.warnings.filter( + (warning) => warning === MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE, + ), + ).toHaveLength(1); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), + }, + }, + ); + }); +}); diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts new file mode 100644 index 00000000000..1e0d5050ab8 --- /dev/null +++ b/src/infra/matrix-legacy-crypto.ts @@ -0,0 +1,493 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + resolveConfiguredMatrixAccountIds, + resolveMatrixLegacyFlatStoragePaths, +} from "../../extensions/matrix/runtime-api.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugin-sdk/json-store.js"; +import { + resolveLegacyMatrixFlatStoreTarget, + resolveMatrixMigrationAccountTarget, +} from "./matrix-migration-config.js"; +import { + MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE, + isMatrixLegacyCryptoInspectorAvailable, + loadMatrixLegacyCryptoInspector, + type MatrixLegacyCryptoInspector, +} from "./matrix-plugin-helper.js"; + +type MatrixLegacyCryptoCounts = { + total: number; + backedUp: number; +}; + +type MatrixLegacyCryptoSummary = { + deviceId: string | null; + roomKeyCounts: MatrixLegacyCryptoCounts | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +type MatrixLegacyCryptoMigrationState = { + version: 1; + source: "matrix-bot-sdk-rust"; + accountId: string; + deviceId: string | null; + roomKeyCounts: MatrixLegacyCryptoCounts | null; + backupVersion: string | null; + decryptionKeyImported: boolean; + restoreStatus: "pending" | "completed" | "manual-action-required"; + detectedAt: string; + restoredAt?: string; + importedCount?: number; + totalCount?: number; + lastError?: string | null; +}; + +type MatrixLegacyCryptoPlan = { + accountId: string; + rootDir: string; + recoveryKeyPath: string; + statePath: string; + legacyCryptoPath: string; + homeserver: string; + userId: string; + accessToken: string; + deviceId: string | null; +}; + +type MatrixLegacyCryptoDetection = { + plans: MatrixLegacyCryptoPlan[]; + warnings: string[]; +}; + +type MatrixLegacyCryptoPreparationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +type MatrixLegacyCryptoPrepareDeps = { + inspectLegacyStore: MatrixLegacyCryptoInspector; + writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; +}; + +type MatrixLegacyBotSdkMetadata = { + deviceId: string | null; +}; + +type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; +}; + +function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): { + detected: boolean; + warning?: string; +} { + try { + const stat = fs.statSync(cryptoRootDir); + if (!stat.isDirectory()) { + return { + detected: false, + warning: + `Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } + } catch (err) { + return { + detected: false, + warning: + `Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } + + try { + return { + detected: + fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) || + fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) || + fs + .readdirSync(cryptoRootDir, { withFileTypes: true }) + .some( + (entry) => + entry.isDirectory() && + fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")), + ), + }; + } catch (err) { + return { + detected: false, + warning: + `Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` + + "OpenClaw skipped automatic crypto migration for that path.", + }; + } +} + +function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { + return resolveConfiguredMatrixAccountIds(cfg); +} + +function resolveLegacyMatrixFlatStorePlan(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoPlan | { warning: string } | null { + const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir)); + if (!fs.existsSync(legacy.cryptoPath)) { + return null; + } + const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath); + if (legacyStore.warning) { + return { warning: legacyStore.warning }; + } + if (!legacyStore.detected) { + return null; + } + + const target = resolveLegacyMatrixFlatStoreTarget({ + cfg: params.cfg, + env: params.env, + detectedPath: legacy.cryptoPath, + detectedKind: "encrypted state", + }); + if ("warning" in target) { + return target; + } + + const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath); + return { + accountId: target.accountId, + rootDir: target.rootDir, + recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), + statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath: legacy.cryptoPath, + homeserver: target.homeserver, + userId: target.userId, + accessToken: target.accessToken, + deviceId: metadata.deviceId ?? target.storedDeviceId, + }; +} + +function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata { + const metadataPath = path.join(cryptoRootDir, "bot-sdk.json"); + const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null }; + try { + if (!fs.existsSync(metadataPath)) { + return fallback; + } + const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as { + deviceId?: unknown; + }; + return { + deviceId: + typeof parsed.deviceId === "string" && parsed.deviceId.trim() ? parsed.deviceId : null, + }; + } catch { + return fallback; + } +} + +function resolveMatrixLegacyCryptoPlans(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoDetection { + const warnings: string[] = []; + const plans: MatrixLegacyCryptoPlan[] = []; + + const flatPlan = resolveLegacyMatrixFlatStorePlan(params); + if (flatPlan) { + if ("warning" in flatPlan) { + warnings.push(flatPlan.warning); + } else { + plans.push(flatPlan); + } + } + + for (const accountId of resolveMatrixAccountIds(params.cfg)) { + const target = resolveMatrixMigrationAccountTarget({ + cfg: params.cfg, + env: params.env, + accountId, + }); + if (!target) { + continue; + } + const legacyCryptoPath = path.join(target.rootDir, "crypto"); + if (!fs.existsSync(legacyCryptoPath)) { + continue; + } + const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath); + if (detectedStore.warning) { + warnings.push(detectedStore.warning); + continue; + } + if (!detectedStore.detected) { + continue; + } + if ( + plans.some( + (plan) => + plan.accountId === accountId && + path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath), + ) + ) { + continue; + } + const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath); + plans.push({ + accountId: target.accountId, + rootDir: target.rootDir, + recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"), + statePath: path.join(target.rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath, + homeserver: target.homeserver, + userId: target.userId, + accessToken: target.accessToken, + deviceId: metadata.deviceId ?? target.storedDeviceId, + }); + } + + return { plans, warnings }; +} + +function loadStoredRecoveryKey(filePath: string): MatrixStoredRecoveryKey | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixStoredRecoveryKey; + } catch { + return null; + } +} + +function loadLegacyCryptoMigrationState(filePath: string): MatrixLegacyCryptoMigrationState | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")) as MatrixLegacyCryptoMigrationState; + } catch { + return null; + } +} + +async function persistLegacyMigrationState(params: { + filePath: string; + state: MatrixLegacyCryptoMigrationState; + writeJsonFileAtomically: typeof writeJsonFileAtomicallyImpl; +}): Promise { + await params.writeJsonFileAtomically(params.filePath, params.state); +} + +export function detectLegacyMatrixCrypto(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoDetection { + const detection = resolveMatrixLegacyCryptoPlans({ + cfg: params.cfg, + env: params.env ?? process.env, + }); + if ( + detection.plans.length > 0 && + !isMatrixLegacyCryptoInspectorAvailable({ + cfg: params.cfg, + env: params.env, + }) + ) { + return { + plans: detection.plans, + warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE], + }; + } + return detection; +} + +export async function autoPrepareLegacyMatrixCrypto(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; + deps?: Partial; +}): Promise { + const env = params.env ?? process.env; + const detection = params.deps?.inspectLegacyStore + ? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }) + : detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + const warnings = [...detection.warnings]; + const changes: string[] = []; + let inspectLegacyStore = params.deps?.inspectLegacyStore; + const writeJsonFileAtomically = + params.deps?.writeJsonFileAtomically ?? writeJsonFileAtomicallyImpl; + if (!inspectLegacyStore) { + try { + inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ + cfg: params.cfg, + env, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (!warnings.includes(message)) { + warnings.push(message); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + return { + migrated: false, + changes, + warnings, + }; + } + } + + for (const plan of detection.plans) { + const existingState = loadLegacyCryptoMigrationState(plan.statePath); + if (existingState?.version === 1) { + continue; + } + if (!plan.deviceId) { + warnings.push( + `Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` + + `OpenClaw will continue, but old encrypted history cannot be recovered automatically.`, + ); + continue; + } + + let summary: MatrixLegacyCryptoSummary; + try { + summary = await inspectLegacyStore({ + cryptoRootDir: plan.legacyCryptoPath, + userId: plan.userId, + deviceId: plan.deviceId, + log: params.log?.info, + }); + } catch (err) { + warnings.push( + `Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`, + ); + continue; + } + + let decryptionKeyImported = false; + if (summary.decryptionKeyBase64) { + const existingRecoveryKey = loadStoredRecoveryKey(plan.recoveryKeyPath); + if ( + existingRecoveryKey?.privateKeyBase64 && + existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64 + ) { + warnings.push( + `Legacy Matrix backup key was found for account "${plan.accountId}", but ${plan.recoveryKeyPath} already contains a different recovery key. Leaving the existing file unchanged.`, + ); + } else if (!existingRecoveryKey?.privateKeyBase64) { + const payload: MatrixStoredRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: null, + privateKeyBase64: summary.decryptionKeyBase64, + }; + try { + await writeJsonFileAtomically(plan.recoveryKeyPath, payload); + changes.push( + `Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`, + ); + decryptionKeyImported = true; + } catch (err) { + warnings.push( + `Failed writing Matrix recovery key for account "${plan.accountId}" (${plan.recoveryKeyPath}): ${String(err)}`, + ); + } + } else { + decryptionKeyImported = true; + } + } + + const localOnlyKeys = + summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp + ? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp + : 0; + if (localOnlyKeys > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` + + "Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.", + ); + } + if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" 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 " after upgrade if they have the recovery key.`, + ); + } + if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) { + warnings.push( + `Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`, + ); + } + // If recovery-key persistence failed, leave the migration state absent so the next startup can retry. + if ( + summary.decryptionKeyBase64 && + !decryptionKeyImported && + !loadStoredRecoveryKey(plan.recoveryKeyPath) + ) { + continue; + } + + const state: MatrixLegacyCryptoMigrationState = { + version: 1, + source: "matrix-bot-sdk-rust", + accountId: plan.accountId, + deviceId: summary.deviceId, + roomKeyCounts: summary.roomKeyCounts, + backupVersion: summary.backupVersion, + decryptionKeyImported, + restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required", + detectedAt: new Date().toISOString(), + lastError: null, + }; + try { + await persistLegacyMigrationState({ + filePath: plan.statePath, + state, + writeJsonFileAtomically, + }); + changes.push( + `Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`, + ); + } catch (err) { + warnings.push( + `Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" (${plan.statePath}): ${String(err)}`, + ); + } + } + + if (changes.length > 0) { + params.log?.info?.( + `matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + + return { + migrated: changes.length > 0, + changes, + warnings, + }; +} diff --git a/src/infra/matrix-legacy-state.test.ts b/src/infra/matrix-legacy-state.test.ts new file mode 100644 index 00000000000..f2b921ad626 --- /dev/null +++ b/src/infra/matrix-legacy-state.test.ts @@ -0,0 +1,244 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./matrix-legacy-state.js"; + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf-8"); +} + +describe("matrix legacy state migration", () => { + it("migrates the flat legacy Matrix store into account-scoped storage", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false); + expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); + }); + }); + + it("uses cached Matrix credentials when the config no longer stores an access token", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-from-cache", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", // pragma: allowlist secret + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected cached credentials to make Matrix migration resolvable"); + } + + expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org"); + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + }); + }); + + it("records which account receives a flat legacy store when multiple Matrix accounts exist", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + defaultAccount: "work", + accounts: { + work: { + homeserver: "https://matrix.example.org", + userId: "@work-bot:example.org", + accessToken: "tok-work", + }, + alerts: { + homeserver: "https://matrix.example.org", + userId: "@alerts-bot:example.org", + accessToken: "tok-alerts", + }, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + expect(detection.accountId).toBe("work"); + expect(detection.selectionNote).toContain('account "work"'); + }); + }); + + it("requires channels.matrix.defaultAccount before migrating a flat store into one of multiple accounts", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + work: { + homeserver: "https://matrix.example.org", + userId: "@work-bot:example.org", + accessToken: "tok-work", + }, + alerts: { + homeserver: "https://matrix.example.org", + userId: "@alerts-bot:example.org", + accessToken: "tok-alerts", + }, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(true); + if (!detection || !("warning" in detection)) { + throw new Error("expected a warning-only Matrix legacy state result"); + } + expect(detection.warning).toContain("channels.matrix.defaultAccount is not set"); + }); + }); + + it("uses scoped Matrix env vars when resolving a flat-store migration target", async () => { + await withTempHome( + async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: {}, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected scoped Matrix env vars to resolve a legacy state plan"); + } + + expect(detection.accountId).toBe("ops"); + expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org"); + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); + }, + { + env: { + MATRIX_OPS_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_USER_ID: "@ops-bot:example.org", + MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env", + }, + }, + ); + }); + + it("migrates flat legacy Matrix state into the only configured non-default account", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + + const detection = detectLegacyMatrixState({ cfg, env: process.env }); + expect(detection && "warning" in detection).toBe(false); + if (!detection || "warning" in detection) { + throw new Error("expected a migratable Matrix legacy state plan"); + } + + expect(detection.accountId).toBe("ops"); + + const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env }); + expect(result.migrated).toBe(true); + expect(result.warnings).toEqual([]); + expect(fs.existsSync(detection.targetStoragePath)).toBe(true); + expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true); + }); + }); +}); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts new file mode 100644 index 00000000000..050ae7dd793 --- /dev/null +++ b/src/infra/matrix-legacy-state.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveMatrixLegacyFlatStoragePaths } from "../../extensions/matrix/runtime-api.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js"; + +export type MatrixLegacyStateMigrationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +type MatrixLegacyStatePlan = { + accountId: string; + legacyStoragePath: string; + legacyCryptoPath: string; + targetRootDir: string; + targetStoragePath: string; + targetCryptoPath: string; + selectionNote?: string; +}; + +function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): { + rootDir: string; + storagePath: string; + cryptoPath: string; +} { + const stateDir = resolveStateDir(env, os.homedir); + return resolveMatrixLegacyFlatStoragePaths(stateDir); +} + +function resolveMatrixMigrationPlan(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): MatrixLegacyStatePlan | { warning: string } | null { + const legacy = resolveLegacyMatrixPaths(params.env); + if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) { + return null; + } + + const target = resolveLegacyMatrixFlatStoreTarget({ + cfg: params.cfg, + env: params.env, + detectedPath: legacy.rootDir, + detectedKind: "state", + }); + if ("warning" in target) { + return target; + } + + return { + accountId: target.accountId, + legacyStoragePath: legacy.storagePath, + legacyCryptoPath: legacy.cryptoPath, + targetRootDir: target.rootDir, + targetStoragePath: path.join(target.rootDir, "bot-storage.json"), + targetCryptoPath: path.join(target.rootDir, "crypto"), + selectionNote: target.selectionNote, + }; +} + +export function detectLegacyMatrixState(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MatrixLegacyStatePlan | { warning: string } | null { + return resolveMatrixMigrationPlan({ + cfg: params.cfg, + env: params.env ?? process.env, + }); +} + +function moveLegacyPath(params: { + sourcePath: string; + targetPath: string; + label: string; + changes: string[]; + warnings: string[]; +}): void { + if (!fs.existsSync(params.sourcePath)) { + return; + } + if (fs.existsSync(params.targetPath)) { + params.warnings.push( + `Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`, + ); + return; + } + try { + fs.mkdirSync(path.dirname(params.targetPath), { recursive: true }); + fs.renameSync(params.sourcePath, params.targetPath); + params.changes.push( + `Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`, + ); + } catch (err) { + params.warnings.push( + `Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`, + ); + } +} + +export async function autoMigrateLegacyMatrixState(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; +}): Promise { + const env = params.env ?? process.env; + const detection = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (!detection) { + return { migrated: false, changes: [], warnings: [] }; + } + if ("warning" in detection) { + params.log?.warn?.(`matrix: ${detection.warning}`); + return { migrated: false, changes: [], warnings: [detection.warning] }; + } + + const changes: string[] = []; + const warnings: string[] = []; + moveLegacyPath({ + sourcePath: detection.legacyStoragePath, + targetPath: detection.targetStoragePath, + label: "sync store", + changes, + warnings, + }); + moveLegacyPath({ + sourcePath: detection.legacyCryptoPath, + targetPath: detection.targetCryptoPath, + label: "crypto store", + changes, + warnings, + }); + + if (changes.length > 0) { + const details = [ + ...changes.map((entry) => `- ${entry}`), + ...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []), + "- No user action required.", + ]; + params.log?.info?.( + `matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`, + ); + } + if (warnings.length > 0) { + params.log?.warn?.( + `matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`, + ); + } + + return { + migrated: changes.length > 0, + changes, + warnings, + }; +} diff --git a/src/infra/matrix-migration-config.test.ts b/src/infra/matrix-migration-config.test.ts new file mode 100644 index 00000000000..9ae032d5887 --- /dev/null +++ b/src/infra/matrix-migration-config.test.ts @@ -0,0 +1,273 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveMatrixMigrationAccountTarget } from "./matrix-migration-config.js"; + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf8"); +} + +describe("resolveMatrixMigrationAccountTarget", () => { + it("reuses stored user identity for token-only configs when the access token matches", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + deviceId: "DEVICE-OPS", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "tok-ops", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe("@ops-bot:example.org"); + expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + }); + }); + + it("ignores stored device IDs from stale cached Matrix credentials", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@old-bot:example.org", + accessToken: "tok-old", + deviceId: "DEVICE-OLD", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@new-bot:example.org", + accessToken: "tok-new", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe("@new-bot:example.org"); + expect(target?.accessToken).toBe("tok-new"); + expect(target?.storedDeviceId).toBeNull(); + }); + }); + + it("does not trust stale stored creds on the same homeserver when the token changes", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@old-bot:example.org", + accessToken: "tok-old", + deviceId: "DEVICE-OLD", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "tok-new", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).toBeNull(); + }); + }); + + it("does not inherit the base userId for non-default token-only accounts", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + writeFile( + path.join(stateDir, "credentials", "matrix", "credentials-ops.json"), + JSON.stringify( + { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + accessToken: "tok-ops", + deviceId: "DEVICE-OPS", + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@base-bot:example.org", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + accessToken: "tok-ops", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).not.toBeNull(); + expect(target?.userId).toBe("@ops-bot:example.org"); + expect(target?.storedDeviceId).toBe("DEVICE-OPS"); + }); + }); + + it("does not inherit the base access token for non-default accounts", async () => { + await withTempHome(async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@base-bot:example.org", + accessToken: "tok-base", + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).toBeNull(); + }); + }); + + it("does not inherit the global Matrix access token for non-default accounts", async () => { + await withTempHome( + async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + ops: { + homeserver: "https://matrix.example.org", + userId: "@ops-bot:example.org", + }, + }, + }, + }, + }; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env: process.env, + accountId: "ops", + }); + + expect(target).toBeNull(); + }, + { + env: { + MATRIX_ACCESS_TOKEN: "tok-global", + }, + }, + ); + }); + + it("uses the same scoped env token encoding as runtime account auth", async () => { + await withTempHome(async () => { + const cfg: OpenClawConfig = { + channels: { + matrix: { + accounts: { + "ops-prod": {}, + }, + }, + }, + }; + const env = { + MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org", + MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org", + MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod", + } as NodeJS.ProcessEnv; + + const target = resolveMatrixMigrationAccountTarget({ + cfg, + env, + accountId: "ops-prod", + }); + + expect(target).not.toBeNull(); + expect(target?.homeserver).toBe("https://matrix.example.org"); + expect(target?.userId).toBe("@ops-prod:example.org"); + expect(target?.accessToken).toBe("tok-ops-prod"); + }); + }); +}); diff --git a/src/infra/matrix-migration-config.ts b/src/infra/matrix-migration-config.ts new file mode 100644 index 00000000000..e0fce130f69 --- /dev/null +++ b/src/infra/matrix-migration-config.ts @@ -0,0 +1,268 @@ +import fs from "node:fs"; +import os from "node:os"; +import { + findMatrixAccountEntry, + getMatrixScopedEnvVarNames, + requiresExplicitMatrixDefaultAccount, + resolveMatrixAccountStringValues, + resolveConfiguredMatrixAccountIds, + resolveMatrixAccountStorageRoot, + resolveMatrixChannelConfig, + resolveMatrixCredentialsPath, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../extensions/matrix/runtime-api.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; +}; + +export type MatrixMigrationAccountTarget = { + accountId: string; + homeserver: string; + userId: string; + accessToken: string; + rootDir: string; + storedDeviceId: string | null; +}; + +export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & { + selectionNote?: string; +}; + +type MatrixLegacyFlatStoreKind = "state" | "encrypted state"; + +function clean(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function resolveScopedMatrixEnvConfig( + accountId: string, + env: NodeJS.ProcessEnv, +): { + homeserver: string; + userId: string; + accessToken: string; +} { + const keys = getMatrixScopedEnvVarNames(accountId); + return { + homeserver: clean(env[keys.homeserver]), + userId: clean(env[keys.userId]), + accessToken: clean(env[keys.accessToken]), + }; +} + +function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): { + homeserver: string; + userId: string; + accessToken: string; +} { + return { + homeserver: clean(env.MATRIX_HOMESERVER), + userId: clean(env.MATRIX_USER_ID), + accessToken: clean(env.MATRIX_ACCESS_TOKEN), + }; +} + +function resolveMatrixAccountConfigEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | null { + return findMatrixAccountEntry(cfg, accountId); +} + +function resolveMatrixFlatStoreSelectionNote( + cfg: OpenClawConfig, + accountId: string, +): string | undefined { + if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) { + return undefined; + } + return ( + `Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` + + `account "${accountId}".` + ); +} + +export function resolveMatrixMigrationConfigFields(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + accountId: string; +}): { + homeserver: string; + userId: string; + accessToken: string; +} { + const channel = resolveMatrixChannelConfig(params.cfg); + const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId); + const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env); + const globalEnv = resolveGlobalMatrixEnvConfig(params.env); + const normalizedAccountId = normalizeAccountId(params.accountId); + const resolvedStrings = resolveMatrixAccountStringValues({ + accountId: normalizedAccountId, + account: { + homeserver: clean(account?.homeserver), + userId: clean(account?.userId), + accessToken: clean(account?.accessToken), + }, + scopedEnv, + channel: { + homeserver: clean(channel?.homeserver), + userId: clean(channel?.userId), + accessToken: clean(channel?.accessToken), + }, + globalEnv, + }); + + return { + homeserver: resolvedStrings.homeserver, + userId: resolvedStrings.userId, + accessToken: resolvedStrings.accessToken, + }; +} + +export function loadStoredMatrixCredentials( + env: NodeJS.ProcessEnv, + accountId: string, +): MatrixStoredCredentials | null { + const stateDir = resolveStateDir(env, os.homedir); + const credentialsPath = resolveMatrixCredentialsPath({ + stateDir, + accountId: normalizeAccountId(accountId), + }); + try { + if (!fs.existsSync(credentialsPath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(credentialsPath, "utf8"), + ) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return { + homeserver: parsed.homeserver, + userId: parsed.userId, + accessToken: parsed.accessToken, + deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined, + }; + } catch { + return null; + } +} + +export function credentialsMatchResolvedIdentity( + stored: MatrixStoredCredentials | null, + identity: { + homeserver: string; + userId: string; + accessToken: string; + }, +): stored is MatrixStoredCredentials { + if (!stored || !identity.homeserver) { + return false; + } + if (!identity.userId) { + if (!identity.accessToken) { + return false; + } + return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken; + } + return stored.homeserver === identity.homeserver && stored.userId === identity.userId; +} + +export function resolveMatrixMigrationAccountTarget(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + accountId: string; +}): MatrixMigrationAccountTarget | null { + const stored = loadStoredMatrixCredentials(params.env, params.accountId); + const resolved = resolveMatrixMigrationConfigFields(params); + const matchingStored = credentialsMatchResolvedIdentity(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId, + accessToken: resolved.accessToken, + }) + ? stored + : null; + const homeserver = resolved.homeserver; + const userId = resolved.userId || matchingStored?.userId || ""; + const accessToken = resolved.accessToken || matchingStored?.accessToken || ""; + if (!homeserver || !userId || !accessToken) { + return null; + } + + const stateDir = resolveStateDir(params.env, os.homedir); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver, + userId, + accessToken, + accountId: params.accountId, + }); + + return { + accountId: params.accountId, + homeserver, + userId, + accessToken, + rootDir, + storedDeviceId: matchingStored?.deviceId ?? null, + }; +} + +export function resolveLegacyMatrixFlatStoreTarget(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + detectedPath: string; + detectedKind: MatrixLegacyFlatStoreKind; +}): MatrixLegacyFlatStoreTarget | { warning: string } { + const channel = resolveMatrixChannelConfig(params.cfg); + if (!channel) { + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` + + 'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.', + }; + } + if (requiresExplicitMatrixDefaultAccount(params.cfg)) { + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` + + 'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.', + }; + } + + const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg); + const target = resolveMatrixMigrationAccountTarget({ + cfg: params.cfg, + env: params.env, + accountId, + }); + if (!target) { + const targetDescription = + params.detectedKind === "state" + ? "the new account-scoped target" + : "the account-scoped target"; + return { + warning: + `Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` + + `(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` + + 'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.', + }; + } + + return { + ...target, + selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId), + }; +} diff --git a/src/infra/matrix-migration-snapshot.test.ts b/src/infra/matrix-migration-snapshot.test.ts new file mode 100644 index 00000000000..2d0fb850109 --- /dev/null +++ b/src/infra/matrix-migration-snapshot.test.ts @@ -0,0 +1,251 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; + +const createBackupArchiveMock = vi.hoisted(() => vi.fn()); + +vi.mock("./backup-create.js", () => ({ + createBackupArchive: (...args: unknown[]) => createBackupArchiveMock(...args), +})); + +import { + hasActionableMatrixMigration, + maybeCreateMatrixMigrationSnapshot, + resolveMatrixMigrationSnapshotMarkerPath, + resolveMatrixMigrationSnapshotOutputDir, +} from "./matrix-migration-snapshot.js"; + +describe("matrix migration snapshots", () => { + afterEach(() => { + createBackupArchiveMock.mockReset(); + }); + + it("creates a backup marker after writing a pre-migration snapshot", async () => { + await withTempHome(async (home) => { + const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz"); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8"); + fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8"); + createBackupArchiveMock.mockResolvedValueOnce({ + createdAt: "2026-03-10T18:00:00.000Z", + archivePath, + includeWorkspace: false, + }); + + const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); + + expect(result).toEqual({ + created: true, + archivePath, + markerPath: resolveMatrixMigrationSnapshotMarkerPath(process.env), + }); + expect(createBackupArchiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + output: resolveMatrixMigrationSnapshotOutputDir(process.env), + includeWorkspace: false, + }), + ); + + const marker = JSON.parse( + fs.readFileSync(resolveMatrixMigrationSnapshotMarkerPath(process.env), "utf8"), + ) as { + archivePath: string; + trigger: string; + }; + expect(marker.archivePath).toBe(archivePath); + expect(marker.trigger).toBe("unit-test"); + }); + }); + + it("reuses an existing snapshot marker when the archive still exists", async () => { + await withTempHome(async (home) => { + const archivePath = path.join(home, "Backups", "openclaw-migrations", "snapshot.tar.gz"); + const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.mkdirSync(path.dirname(markerPath), { recursive: true }); + fs.writeFileSync(archivePath, "archive", "utf8"); + fs.writeFileSync( + markerPath, + JSON.stringify({ + version: 1, + createdAt: "2026-03-10T18:00:00.000Z", + archivePath, + trigger: "older-run", + includeWorkspace: false, + }), + "utf8", + ); + + const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); + + expect(result.created).toBe(false); + expect(result.archivePath).toBe(archivePath); + expect(createBackupArchiveMock).not.toHaveBeenCalled(); + }); + }); + + it("recreates the snapshot when the marker exists but the archive is missing", async () => { + await withTempHome(async (home) => { + const markerPath = resolveMatrixMigrationSnapshotMarkerPath(process.env); + const replacementArchivePath = path.join( + home, + "Backups", + "openclaw-migrations", + "replacement.tar.gz", + ); + fs.mkdirSync(path.dirname(markerPath), { recursive: true }); + fs.mkdirSync(path.dirname(replacementArchivePath), { recursive: true }); + fs.writeFileSync( + markerPath, + JSON.stringify({ + version: 1, + createdAt: "2026-03-10T18:00:00.000Z", + archivePath: path.join(home, "Backups", "openclaw-migrations", "missing.tar.gz"), + trigger: "older-run", + includeWorkspace: false, + }), + "utf8", + ); + createBackupArchiveMock.mockResolvedValueOnce({ + createdAt: "2026-03-10T19:00:00.000Z", + archivePath: replacementArchivePath, + includeWorkspace: false, + }); + + const result = await maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" }); + + expect(result.created).toBe(true); + expect(result.archivePath).toBe(replacementArchivePath); + const marker = JSON.parse(fs.readFileSync(markerPath, "utf8")) as { archivePath: string }; + expect(marker.archivePath).toBe(replacementArchivePath); + }); + }); + + it("surfaces backup creation failures without writing a marker", async () => { + await withTempHome(async () => { + createBackupArchiveMock.mockRejectedValueOnce(new Error("backup failed")); + + await expect(maybeCreateMatrixMigrationSnapshot({ trigger: "unit-test" })).rejects.toThrow( + "backup failed", + ); + expect(fs.existsSync(resolveMatrixMigrationSnapshotMarkerPath(process.env))).toBe(false); + }); + }); + + it("does not treat warning-only Matrix migration as actionable", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + fs.mkdirSync(path.join(stateDir, "matrix", "crypto"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"legacy":true}', + "utf8", + ); + fs.writeFileSync( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + }, + }, + }), + "utf8", + ); + + expect( + hasActionableMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + }, + }, + } as never, + env: process.env, + }), + ).toBe(false); + }); + }); + + it("treats resolvable Matrix legacy state as actionable", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"legacy":true}', + "utf8", + ); + + expect( + hasActionableMatrixMigration({ + cfg: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never, + env: process.env, + }), + ).toBe(true); + }); + }); + + it("treats legacy Matrix crypto as warning-only until the plugin helper is available", async () => { + await withTempHome( + async (home) => { + const stateDir = path.join(home, ".openclaw"); + fs.mkdirSync(path.join(home, "empty-bundled"), { recursive: true }); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICE123" }), + "utf8", + ); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + } as never; + + const detection = detectLegacyMatrixCrypto({ + cfg, + env: process.env, + }); + expect(detection.plans).toHaveLength(1); + expect(detection.warnings).toContain( + "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.", + ); + expect( + hasActionableMatrixMigration({ + cfg, + env: process.env, + }), + ).toBe(false); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), + }, + }, + ); + }); +}); diff --git a/src/infra/matrix-migration-snapshot.ts b/src/infra/matrix-migration-snapshot.ts new file mode 100644 index 00000000000..ff3129be554 --- /dev/null +++ b/src/infra/matrix-migration-snapshot.ts @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js"; +import { createBackupArchive } from "./backup-create.js"; +import { resolveRequiredHomeDir } from "./home-dir.js"; +import { detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; +import { detectLegacyMatrixState } from "./matrix-legacy-state.js"; +import { isMatrixLegacyCryptoInspectorAvailable } from "./matrix-plugin-helper.js"; + +const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations"; + +type MatrixMigrationSnapshotMarker = { + version: 1; + createdAt: string; + archivePath: string; + trigger: string; + includeWorkspace: boolean; +}; + +export type MatrixMigrationSnapshotResult = { + created: boolean; + archivePath: string; + markerPath: string; +}; + +function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(filePath, "utf8"), + ) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.archivePath !== "string" || + typeof parsed.trigger !== "string" + ) { + return null; + } + return { + version: 1, + createdAt: parsed.createdAt, + archivePath: parsed.archivePath, + trigger: parsed.trigger, + includeWorkspace: parsed.includeWorkspace === true, + }; + } catch { + return null; + } +} + +export function resolveMatrixMigrationSnapshotMarkerPath( + env: NodeJS.ProcessEnv = process.env, +): string { + const stateDir = resolveStateDir(env, os.homedir); + return path.join(stateDir, "matrix", "migration-snapshot.json"); +} + +export function resolveMatrixMigrationSnapshotOutputDir( + env: NodeJS.ProcessEnv = process.env, +): string { + const homeDir = resolveRequiredHomeDir(env, os.homedir); + return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME); +} + +export function hasPendingMatrixMigration(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = params.env ?? process.env; + const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (legacyState) { + return true; + } + const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0; +} + +export function hasActionableMatrixMigration(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): boolean { + const env = params.env ?? process.env; + const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env }); + if (legacyState && !("warning" in legacyState)) { + return true; + } + const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env }); + return ( + legacyCrypto.plans.length > 0 && + isMatrixLegacyCryptoInspectorAvailable({ + cfg: params.cfg, + env, + }) + ); +} + +export async function maybeCreateMatrixMigrationSnapshot(params: { + trigger: string; + env?: NodeJS.ProcessEnv; + outputDir?: string; + log?: { info?: (message: string) => void; warn?: (message: string) => void }; +}): Promise { + const env = params.env ?? process.env; + const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env); + const existingMarker = loadSnapshotMarker(markerPath); + if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) { + params.log?.info?.( + `matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`, + ); + return { + created: false, + archivePath: existingMarker.archivePath, + markerPath, + }; + } + if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) { + params.log?.warn?.( + `matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`, + ); + } + + const snapshot = await createBackupArchive({ + output: (() => { + const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env); + fs.mkdirSync(outputDir, { recursive: true }); + return outputDir; + })(), + includeWorkspace: false, + }); + + const marker: MatrixMigrationSnapshotMarker = { + version: 1, + createdAt: snapshot.createdAt, + archivePath: snapshot.archivePath, + trigger: params.trigger, + includeWorkspace: snapshot.includeWorkspace, + }; + await writeJsonFileAtomically(markerPath, marker); + params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`); + return { + created: true, + archivePath: snapshot.archivePath, + markerPath, + }; +} diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts new file mode 100644 index 00000000000..650edc434ca --- /dev/null +++ b/src/infra/matrix-plugin-helper.test.ts @@ -0,0 +1,186 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import { + isMatrixLegacyCryptoInspectorAvailable, + loadMatrixLegacyCryptoInspector, +} from "./matrix-plugin-helper.js"; + +function writeMatrixPluginFixture(rootDir: string, helperBody: string): void { + fs.mkdirSync(rootDir, { recursive: true }); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + id: "matrix", + configSchema: { + type: "object", + additionalProperties: false, + }, + }), + "utf8", + ); + fs.writeFileSync(path.join(rootDir, "index.js"), "export default {};\n", "utf8"); + fs.writeFileSync(path.join(rootDir, "legacy-crypto-inspector.js"), helperBody, "utf8"); +} + +describe("matrix plugin helper resolution", () => { + it("loads the legacy crypto inspector from the bundled matrix plugin", async () => { + await withTempHome( + async (home) => { + const bundledRoot = path.join(home, "bundled", "matrix"); + writeMatrixPluginFixture( + bundledRoot, + [ + "export async function inspectLegacyMatrixCryptoStore() {", + ' return { deviceId: "BUNDLED", roomKeyCounts: { total: 7, backedUp: 6 }, backupVersion: "1", decryptionKeyBase64: "YWJjZA==" };', + "}", + ].join("\n"), + ); + + const cfg = {} as const; + + expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); + const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ + cfg, + env: process.env, + }); + + await expect( + inspectLegacyStore({ + cryptoRootDir: "/tmp/legacy", + userId: "@bot:example.org", + deviceId: "DEVICE123", + }), + ).resolves.toEqual({ + deviceId: "BUNDLED", + roomKeyCounts: { total: 7, backedUp: 6 }, + backupVersion: "1", + decryptionKeyBase64: "YWJjZA==", + }); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"), + }, + }, + ); + }); + + it("prefers configured plugin load paths over bundled matrix plugins", async () => { + await withTempHome( + async (home) => { + const bundledRoot = path.join(home, "bundled", "matrix"); + const customRoot = path.join(home, "plugins", "matrix-local"); + writeMatrixPluginFixture( + bundledRoot, + [ + "export async function inspectLegacyMatrixCryptoStore() {", + ' return { deviceId: "BUNDLED", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };', + "}", + ].join("\n"), + ); + writeMatrixPluginFixture( + customRoot, + [ + "export default async function inspectLegacyMatrixCryptoStore() {", + ' return { deviceId: "CONFIG", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null };', + "}", + ].join("\n"), + ); + + const cfg = { + plugins: { + load: { + paths: [customRoot], + }, + }, + } as const; + + expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); + const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ + cfg, + env: process.env, + }); + + await expect( + inspectLegacyStore({ + cryptoRootDir: "/tmp/legacy", + userId: "@bot:example.org", + deviceId: "DEVICE123", + }), + ).resolves.toEqual({ + deviceId: "CONFIG", + roomKeyCounts: null, + backupVersion: null, + decryptionKeyBase64: null, + }); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "bundled"), + }, + }, + ); + }); + + it("rejects helper files that escape the plugin root", async () => { + await withTempHome( + async (home) => { + const customRoot = path.join(home, "plugins", "matrix-local"); + const outsideRoot = path.join(home, "outside"); + fs.mkdirSync(customRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.writeFileSync( + path.join(customRoot, "openclaw.plugin.json"), + JSON.stringify({ + id: "matrix", + configSchema: { + type: "object", + additionalProperties: false, + }, + }), + "utf8", + ); + fs.writeFileSync(path.join(customRoot, "index.js"), "export default {};\n", "utf8"); + const outsideHelper = path.join(outsideRoot, "legacy-crypto-inspector.js"); + fs.writeFileSync( + outsideHelper, + 'export default async function inspectLegacyMatrixCryptoStore() { return { deviceId: "ESCAPE", roomKeyCounts: null, backupVersion: null, decryptionKeyBase64: null }; }\n', + "utf8", + ); + + try { + fs.symlinkSync( + outsideHelper, + path.join(customRoot, "legacy-crypto-inspector.js"), + process.platform === "win32" ? "file" : undefined, + ); + } catch { + return; + } + + const cfg = { + plugins: { + load: { + paths: [customRoot], + }, + }, + } as const; + + expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false); + await expect( + loadMatrixLegacyCryptoInspector({ + cfg, + env: process.env, + }), + ).rejects.toThrow("Matrix plugin helper path is unsafe"); + }, + { + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: (home) => path.join(home, "empty-bundled"), + }, + }, + ); + }); +}); diff --git a/src/infra/matrix-plugin-helper.ts b/src/infra/matrix-plugin-helper.ts new file mode 100644 index 00000000000..ab40287029f --- /dev/null +++ b/src/infra/matrix-plugin-helper.ts @@ -0,0 +1,173 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createJiti } from "jiti"; +import type { OpenClawConfig } from "../config/config.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRecord, +} from "../plugins/manifest-registry.js"; +import { openBoundaryFileSync } from "./boundary-file-read.js"; + +const MATRIX_PLUGIN_ID = "matrix"; +const MATRIX_HELPER_CANDIDATES = [ + "legacy-crypto-inspector.ts", + "legacy-crypto-inspector.js", + path.join("dist", "legacy-crypto-inspector.js"), +] as const; + +export const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE = + "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."; + +type MatrixLegacyCryptoInspectorParams = { + cryptoRootDir: string; + userId: string; + deviceId: string; + log?: (message: string) => void; +}; + +type MatrixLegacyCryptoInspectorResult = { + deviceId: string | null; + roomKeyCounts: { + total: number; + backedUp: number; + } | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +export type MatrixLegacyCryptoInspector = ( + params: MatrixLegacyCryptoInspectorParams, +) => Promise; + +function resolveMatrixPluginRecord(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): PluginManifestRecord | null { + const registry = loadPluginManifestRegistry({ + config: params.cfg, + workspaceDir: params.workspaceDir, + env: params.env, + }); + return registry.plugins.find((plugin) => plugin.id === MATRIX_PLUGIN_ID) ?? null; +} + +type MatrixLegacyCryptoInspectorPathResolution = + | { status: "ok"; helperPath: string } + | { status: "missing" } + | { status: "unsafe"; candidatePath: string }; + +function resolveMatrixLegacyCryptoInspectorPath(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): MatrixLegacyCryptoInspectorPathResolution { + const plugin = resolveMatrixPluginRecord(params); + if (!plugin) { + return { status: "missing" }; + } + for (const relativePath of MATRIX_HELPER_CANDIDATES) { + const candidatePath = path.join(plugin.rootDir, relativePath); + const opened = openBoundaryFileSync({ + absolutePath: candidatePath, + rootPath: plugin.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: plugin.origin !== "bundled", + allowedType: "file", + }); + if (opened.ok) { + fs.closeSync(opened.fd); + return { status: "ok", helperPath: opened.path }; + } + if (opened.reason !== "path") { + return { status: "unsafe", candidatePath }; + } + } + return { status: "missing" }; +} + +export function isMatrixLegacyCryptoInspectorAvailable(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): boolean { + return resolveMatrixLegacyCryptoInspectorPath(params).status === "ok"; +} + +let jitiLoader: ReturnType | null = null; +const inspectorCache = new Map>(); + +function getJiti() { + if (!jitiLoader) { + jitiLoader = createJiti(import.meta.url, { + interopDefault: false, + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"], + }); + } + return jitiLoader; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function resolveInspectorExport(loaded: unknown): MatrixLegacyCryptoInspector | null { + if (!isObjectRecord(loaded)) { + return null; + } + const directInspector = loaded.inspectLegacyMatrixCryptoStore; + if (typeof directInspector === "function") { + return directInspector as MatrixLegacyCryptoInspector; + } + const directDefault = loaded.default; + if (typeof directDefault === "function") { + return directDefault as MatrixLegacyCryptoInspector; + } + if (!isObjectRecord(directDefault)) { + return null; + } + const nestedInspector = directDefault.inspectLegacyMatrixCryptoStore; + return typeof nestedInspector === "function" + ? (nestedInspector as MatrixLegacyCryptoInspector) + : null; +} + +export async function loadMatrixLegacyCryptoInspector(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; +}): Promise { + const resolution = resolveMatrixLegacyCryptoInspectorPath(params); + if (resolution.status === "missing") { + throw new Error(MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE); + } + if (resolution.status === "unsafe") { + throw new Error( + `Matrix plugin helper path is unsafe: ${resolution.candidatePath}. Reinstall @openclaw/matrix and try again.`, + ); + } + const helperPath = resolution.helperPath; + + const cached = inspectorCache.get(helperPath); + if (cached) { + return await cached; + } + + const pending = (async () => { + const loaded: unknown = await getJiti().import(helperPath); + const inspectLegacyMatrixCryptoStore = resolveInspectorExport(loaded); + if (!inspectLegacyMatrixCryptoStore) { + throw new Error( + `Matrix plugin helper at ${helperPath} does not export inspectLegacyMatrixCryptoStore(). Reinstall @openclaw/matrix and try again.`, + ); + } + return inspectLegacyMatrixCryptoStore; + })(); + inspectorCache.set(helperPath, pending); + try { + return await pending; + } catch (err) { + inspectorCache.delete(helperPath); + throw err; + } +} diff --git a/src/infra/outbound/conversation-id.test.ts b/src/infra/outbound/conversation-id.test.ts index 68865219c37..d359c2b21e5 100644 --- a/src/infra/outbound/conversation-id.test.ts +++ b/src/infra/outbound/conversation-id.test.ts @@ -33,6 +33,26 @@ describe("resolveConversationIdFromTargets", () => { targets: ["channel: 987654321 "], expected: "987654321", }, + { + name: "extracts room ids from Matrix room targets", + targets: ["room:!room:example.org"], + expected: "!room:example.org", + }, + { + name: "extracts ids from explicit conversation targets", + targets: ["conversation:19:abc@thread.tacv2"], + expected: "19:abc@thread.tacv2", + }, + { + name: "extracts ids from explicit group targets", + targets: ["group:1471383327500481391"], + expected: "1471383327500481391", + }, + { + name: "extracts ids from explicit dm targets", + targets: ["dm:alice"], + expected: "alice", + }, { name: "extracts ids from Discord channel mentions", targets: ["<#1475250310120214812>"], diff --git a/src/infra/outbound/conversation-id.ts b/src/infra/outbound/conversation-id.ts index a6f8ed1fd6b..6b9050346a7 100644 --- a/src/infra/outbound/conversation-id.ts +++ b/src/infra/outbound/conversation-id.ts @@ -6,6 +6,15 @@ function normalizeConversationId(value: unknown): string | undefined { return trimmed || undefined; } +function resolveExplicitConversationTargetId(target: string): string | undefined { + for (const prefix of ["channel:", "conversation:", "group:", "room:", "dm:"]) { + if (target.toLowerCase().startsWith(prefix)) { + return normalizeConversationId(target.slice(prefix.length)); + } + } + return undefined; +} + export function resolveConversationIdFromTargets(params: { threadId?: string | number; targets: Array; @@ -21,11 +30,11 @@ export function resolveConversationIdFromTargets(params: { if (!target) { continue; } - if (target.startsWith("channel:")) { - const channelId = normalizeConversationId(target.slice("channel:".length)); - if (channelId) { - return channelId; - } + const explicitConversationId = resolveExplicitConversationTargetId(target); + if (explicitConversationId) { + return explicitConversationId; + } + if (target.includes(":") && explicitConversationId === undefined) { continue; } const mentionMatch = target.match(/^<#(\d+)>$/); diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 710bfb5eb40..b1cfd8c5195 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -8,16 +8,27 @@ export { jsonResult, readNumberParam, readReactionParams, + readStringArrayParam, readStringParam, } from "../agents/tools/common.js"; export type { ReplyPayload } from "../auto-reply/types.js"; +export { resolveAckReaction } from "../agents/identity.js"; export { compileAllowlist, resolveCompiledAllowlistMatch, resolveAllowlistCandidates, resolveAllowlistMatchByCandidates, } from "../channels/allowlist-match.js"; -export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../channels/allowlists/resolve-utils.js"; +export { ensureConfiguredAcpBindingReady } from "../acp/persistent-bindings.lifecycle.js"; +export { resolveConfiguredAcpBindingRecord } from "../acp/persistent-bindings.resolve.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; @@ -28,6 +39,7 @@ export { buildChannelKeyCandidates, resolveChannelEntryMatch, } from "../channels/plugins/channel-config.js"; +export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export { deleteAccountFromConfigSection, setAccountEnabledInConfigSection, @@ -38,12 +50,16 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, + promptAccountId, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, } from "../channels/plugins/setup-wizard-helpers.js"; +export { promptChannelAccessConfig } from "../channels/plugins/setup-group-access.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; -export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; +export { + applyAccountNameToChannelSection, + moveSingleAccountChannelSectionToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; export type { BaseProbeResult, ChannelDirectoryEntry, @@ -51,12 +67,22 @@ export type { ChannelMessageActionAdapter, ChannelMessageActionContext, ChannelMessageActionName, + ChannelMessageToolDiscovery, + ChannelMessageToolSchemaContribution, ChannelOutboundAdapter, ChannelResolveKind, ChannelResolveResult, + ChannelSetupInput, ChannelToolSend, } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; +export { resolveThreadBindingFarewellText } from "../channels/thread-bindings-messages.js"; +export { + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, +} from "../channels/thread-bindings-policy.js"; +export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; export { @@ -80,34 +106,62 @@ export { } from "./secret-input.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { maybeCreateMatrixMigrationSnapshot } from "../infra/matrix-migration-snapshot.js"; +export { + getSessionBindingService, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../infra/outbound/session-binding-service.js"; +export { resolveOutboundSendDep } from "../infra/outbound/send-deps.js"; +export type { + BindingTargetKind, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; +export { isPrivateOrLoopbackHost } from "../gateway/net.js"; +export { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export type { PollInput } from "../polls.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export type { RuntimeEnv } from "../runtime.js"; +export { normalizePollInput } from "../polls.js"; export { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../security/dm-policy-shared.js"; -export { formatDocsLink } from "../terminal/links.js"; + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; +export type { RuntimeEnv } from "../runtime.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { redactSensitiveText } from "../logging/redact.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, } from "./group-access.js"; export { createChannelPairingController } from "./channel-pairing.js"; +export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { runPluginCommandWithTimeout } from "./run-command.js"; -export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js"; -export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; +export { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "../../extensions/matrix/helper-api.js"; +export { getMatrixScopedEnvVarNames } from "../../extensions/matrix/helper-api.js"; +export { + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, +} from "../../extensions/matrix/helper-api.js"; const matrixSetup = createOptionalChannelSetupSurface({ channel: "matrix", diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 67c7cbbcede..1328e03977b 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { + formatConversationTarget, deliveryContextKey, deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, normalizeSessionDeliveryFields, + resolveConversationDeliveryTarget, } from "./delivery-context.js"; describe("delivery context helpers", () => { @@ -77,6 +79,36 @@ describe("delivery context helpers", () => { ); }); + it("formats channel-aware conversation targets", () => { + expect(formatConversationTarget({ channel: "discord", conversationId: "123" })).toBe( + "channel:123", + ); + expect(formatConversationTarget({ channel: "matrix", conversationId: "!room:example" })).toBe( + "room:!room:example", + ); + expect( + formatConversationTarget({ + channel: "matrix", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toBe("room:!room:example"); + expect(formatConversationTarget({ channel: "matrix", conversationId: " " })).toBeUndefined(); + }); + + it("resolves delivery targets for Matrix child threads", () => { + expect( + resolveConversationDeliveryTarget({ + channel: "matrix", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toEqual({ + to: "room:!room:example", + threadId: "$thread", + }); + }); + it("derives delivery context from a session entry", () => { expect( deliveryContextFromSession({ diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 2fadcac0851..7eeb75d02c6 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -49,6 +49,75 @@ export function normalizeDeliveryContext(context?: DeliveryContext): DeliveryCon return normalized; } +export function formatConversationTarget(params: { + channel?: string; + conversationId?: string | number; + parentConversationId?: string | number; +}): string | undefined { + const channel = + typeof params.channel === "string" + ? (normalizeMessageChannel(params.channel) ?? params.channel.trim()) + : undefined; + const conversationId = + typeof params.conversationId === "number" && Number.isFinite(params.conversationId) + ? String(Math.trunc(params.conversationId)) + : typeof params.conversationId === "string" + ? params.conversationId.trim() + : undefined; + if (!channel || !conversationId) { + return undefined; + } + if (channel === "matrix") { + const parentConversationId = + typeof params.parentConversationId === "number" && + Number.isFinite(params.parentConversationId) + ? String(Math.trunc(params.parentConversationId)) + : typeof params.parentConversationId === "string" + ? params.parentConversationId.trim() + : undefined; + const roomId = + parentConversationId && parentConversationId !== conversationId + ? parentConversationId + : conversationId; + return `room:${roomId}`; + } + return `channel:${conversationId}`; +} + +export function resolveConversationDeliveryTarget(params: { + channel?: string; + conversationId?: string | number; + parentConversationId?: string | number; +}): { to?: string; threadId?: string } { + const to = formatConversationTarget(params); + const channel = + typeof params.channel === "string" + ? (normalizeMessageChannel(params.channel) ?? params.channel.trim()) + : undefined; + const conversationId = + typeof params.conversationId === "number" && Number.isFinite(params.conversationId) + ? String(Math.trunc(params.conversationId)) + : typeof params.conversationId === "string" + ? params.conversationId.trim() + : undefined; + const parentConversationId = + typeof params.parentConversationId === "number" && Number.isFinite(params.parentConversationId) + ? String(Math.trunc(params.parentConversationId)) + : typeof params.parentConversationId === "string" + ? params.parentConversationId.trim() + : undefined; + if ( + channel === "matrix" && + to && + conversationId && + parentConversationId && + parentConversationId !== conversationId + ) { + return { to, threadId: conversationId }; + } + return { to }; +} + export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): { deliveryContext?: DeliveryContext; lastChannel?: string;