diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md deleted file mode 100644 index 04a6a8801e3..00000000000 --- a/docs/channels/matrix-js.md +++ /dev/null @@ -1,391 +0,0 @@ ---- -summary: "Matrix-js support status, setup, and configuration examples" -read_when: - - Setting up Matrix-js in OpenClaw - - Configuring Matrix E2EE and verification -title: "Matrix-js" ---- - -# Matrix-js (plugin) - -Matrix-js is the current Matrix channel plugin for OpenClaw. -It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE. - -For new setups, use Matrix-js. -If you need legacy compatibility with `@vector-im/matrix-bot-sdk`, use [Matrix (legacy)](/channels/matrix). - -## Plugin required - -Matrix-js is a plugin and is not bundled with core OpenClaw. - -Install from npm: - -```bash -openclaw plugins install @openclaw/matrix-js -``` - -Install from a local checkout: - -```bash -openclaw plugins install ./extensions/matrix-js -``` - -See [Plugins](/tools/plugin) for plugin behavior and install rules. - -## Setup - -1. Install the plugin. -2. Create a Matrix account on your homeserver. -3. Configure `channels["matrix-js"]` with either: - - `homeserver` + `accessToken`, or - - `homeserver` + `userId` + `password`. -4. Restart the gateway. -5. Start a DM with the bot or invite it to a room. - -Minimal token-based setup: - -```json5 -{ - channels: { - "matrix-js": { - enabled: true, - homeserver: "https://matrix.example.org", - accessToken: "syt_xxx", - dm: { policy: "pairing" }, - }, - }, -} -``` - -Password-based setup (token is cached after login): - -```json5 -{ - channels: { - "matrix-js": { - enabled: true, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "replace-me", - deviceName: "OpenClaw Gateway", - }, - }, -} -``` - -Matrix-js stores cached credentials in `~/.openclaw/credentials/matrix/`. -The default account uses `credentials.json`; named accounts use `credentials-.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` - -## Configuration example - -This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled: - -```json5 -{ - channels: { - "matrix-js": { - enabled: true, - homeserver: "https://matrix.example.org", - accessToken: "syt_xxx", - encryption: true, - - dm: { - policy: "pairing", - }, - - groupPolicy: "allowlist", - groupAllowFrom: ["@admin:example.org"], - groups: { - "!roomid:example.org": { - requireMention: true, - }, - }, - - autoJoin: "allowlist", - autoJoinAllowlist: ["!roomid:example.org"], - threadReplies: "inbound", - replyToMode: "off", - }, - }, -} -``` - -## E2EE setup - -Enable encryption: - -```json5 -{ - channels: { - "matrix-js": { - enabled: true, - homeserver: "https://matrix.example.org", - accessToken: "syt_xxx", - encryption: true, - dm: { policy: "pairing" }, - }, - }, -} -``` - -Check verification status: - -```bash -openclaw matrix-js verify status -``` - -Verbose status (full diagnostics): - -```bash -openclaw matrix-js verify status --verbose -``` - -Bootstrap cross-signing and verification state: - -```bash -openclaw matrix-js verify bootstrap -``` - -Verbose bootstrap diagnostics: - -```bash -openclaw matrix-js verify bootstrap --verbose -``` - -Verify this device with a recovery key: - -```bash -openclaw matrix-js verify device "" -``` - -Verbose device verification details: - -```bash -openclaw matrix-js verify device "" --verbose -``` - -Check room-key backup health: - -```bash -openclaw matrix-js verify backup status -``` - -Verbose backup health diagnostics: - -```bash -openclaw matrix-js verify backup status --verbose -``` - -Restore room keys from server backup: - -```bash -openclaw matrix-js verify backup restore -``` - -Verbose restore diagnostics: - -```bash -openclaw matrix-js verify backup restore --verbose -``` - -All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`. -Use `--json` for full machine-readable output when scripting. - -When `encryption: true`, Matrix-js defaults `startupVerification` to `"if-unverified"`. -On startup, if this device is still unverified, Matrix-js will request self-verification in another Matrix client, -skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts. -Failed request attempts retry sooner than successful request creation by default. -Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours` -if you want a shorter or longer retry window. - -Encrypted runtime state is stored per account and per access token in -`~/.openclaw/matrix/accounts//__//`. -That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`), -recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`), -thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`) -when those features are in use. - -## Automatic verification notices - -Matrix-js now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages. -That includes: - -- verification request notices -- verification ready notices (with explicit "Verify by emoji" guidance) -- verification start and completion notices -- SAS details (emoji and decimal) when available - -Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match" -in their Matrix client, verification completes without requiring a manual OpenClaw tool step. -Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`. - -## Threads - -Matrix-js supports native Matrix threads for both automatic replies and message-tool sends. - -- `threadReplies: "off"` keeps replies top-level. -- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread. -- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message. -- Inbound threaded messages include the thread root message as extra agent context. -- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided. -- Runtime thread bindings are supported for Matrix-js. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs. -- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`. -- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead. - -### Thread Binding Config - -Matrix-js inherits global defaults from `session.threadBindings`, and also supports per-channel overrides: - -- `threadBindings.enabled` -- `threadBindings.idleHours` -- `threadBindings.maxAgeHours` -- `threadBindings.spawnSubagentSessions` -- `threadBindings.spawnAcpSessions` - -Matrix-js thread-bound spawn flags are opt-in: - -- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads. -- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads. - -## Reactions - -Matrix-js supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions. - -- Outbound reaction tooling is gated by `channels["matrix-js"].actions.reactions`. -- `react` adds a reaction to a specific Matrix event. -- `reactions` lists the current reaction summary for a specific Matrix event. -- `emoji=""` removes the bot account's own reactions on that event. -- `remove: true` removes only the specified emoji reaction from the bot account. - -Ack reactions use the standard OpenClaw resolution order: - -- `channels["matrix-js"].accounts..ackReaction` -- `channels["matrix-js"].ackReaction` -- `messages.ackReaction` -- agent identity emoji fallback - -Ack reaction scope resolves in this order: - -- `channels["matrix-js"].accounts..ackReactionScope` -- `channels["matrix-js"].ackReactionScope` -- `messages.ackReactionScope` - -Reaction notification mode resolves in this order: - -- `channels["matrix-js"].accounts..reactionNotifications` -- `channels["matrix-js"].reactionNotifications` -- default: `own` - -Current behavior: - -- `reactionNotifications: "own"` forwards added `m.reaction` events when they target bot-authored Matrix messages. -- `reactionNotifications: "off"` disables reaction system events. -- Reaction removals are still not synthesized into system events because Matrix surfaces those as redactions, not as standalone `m.reaction` removals. - -## DM and room policy example - -```json5 -{ - channels: { - "matrix-js": { - dm: { - policy: "allowlist", - allowFrom: ["@admin:example.org"], - }, - groupPolicy: "allowlist", - groupAllowFrom: ["@admin:example.org"], - groups: { - "!roomid:example.org": { - requireMention: true, - }, - }, - }, - }, -} -``` - -See [Groups](/channels/groups) for mention-gating and allowlist behavior. - -## Multi-account example - -```json5 -{ - channels: { - "matrix-js": { - enabled: true, - dm: { policy: "pairing" }, - accounts: { - assistant: { - homeserver: "https://matrix.example.org", - accessToken: "syt_assistant_xxx", - encryption: true, - }, - alerts: { - homeserver: "https://matrix.example.org", - accessToken: "syt_alerts_xxx", - dm: { - policy: "allowlist", - allowFrom: ["@ops:example.org"], - }, - }, - }, - }, - }, -} -``` - -## Configuration reference - -- `enabled`: enable or disable the channel. -- `homeserver`: homeserver URL, for example `https://matrix.example.org`. -- `userId`: full Matrix user ID, for example `@bot:example.org`. -- `accessToken`: access token for token-based auth. -- `password`: password for password-based login. -- `deviceId`: explicit Matrix device ID. -- `deviceName`: device display name for password login. -- `initialSyncLimit`: startup sync event limit. -- `encryption`: enable E2EE. -- `allowlistOnly`: force allowlist-only behavior for DMs and rooms. -- `groupPolicy`: `open`, `allowlist`, or `disabled`. -- `groupAllowFrom`: allowlist of user IDs for room traffic. -- `replyToMode`: `off`, `first`, or `all`. -- `threadReplies`: `off`, `inbound`, or `always`. -- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle. -- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`). -- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests. -- `textChunkLimit`: outbound message chunk size. -- `chunkMode`: `length` or `newline`. -- `responsePrefix`: optional message prefix for outbound replies. -- `ackReaction`: optional ack reaction override for this channel/account. -- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`). -- `reactionNotifications`: inbound reaction notification mode (`own`, `off`). -- `mediaMaxMb`: outbound media size cap in MB. -- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`). -- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. -- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`). -- `groups`: per-room policy map. -- `rooms`: legacy alias for `groups`. -- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`). diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 9bb56d1ddb7..2344a52685c 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -1,83 +1,45 @@ --- -summary: "Matrix support status, capabilities, and configuration" +summary: "Matrix support status, setup, and configuration examples" read_when: - - Working on Matrix channel features + - Setting up Matrix in OpenClaw + - Configuring Matrix E2EE and verification title: "Matrix" --- # Matrix (plugin) -Matrix is an open, decentralized messaging protocol. OpenClaw connects as a Matrix **user** -on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM -the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, -but it requires E2EE to be enabled. - -Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, -polls (send + poll-start as text), location, and E2EE (with crypto support). +Matrix is the Matrix channel plugin for OpenClaw. +It uses the official `matrix-js-sdk` and supports DMs, rooms, threads, media, reactions, polls, location, and E2EE. ## Plugin required -Matrix ships as a plugin and is not bundled with the core install. +Matrix is a plugin and is not bundled with core OpenClaw. -Install via CLI (npm registry): +Install from npm: ```bash openclaw plugins install @openclaw/matrix ``` -Local checkout (when running from a git repo): +Install from a local checkout: ```bash openclaw plugins install ./extensions/matrix ``` -If you choose Matrix during configure/onboarding and a git checkout is detected, -OpenClaw will offer the local install path automatically. - -Details: [Plugins](/tools/plugin) +See [Plugins](/tools/plugin) for plugin behavior and install rules. ## Setup -1. Install the Matrix plugin: - - From npm: `openclaw plugins install @openclaw/matrix` - - From a local checkout: `openclaw plugins install ./extensions/matrix` -2. Create a Matrix account on a homeserver: - - Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/) - - Or host it yourself. -3. Get an access token for the bot account: - - Use the Matrix login API with `curl` at your home server: +1. Install the plugin. +2. Create a Matrix account on your homeserver. +3. Configure `channels.matrix` with either: + - `homeserver` + `accessToken`, or + - `homeserver` + `userId` + `password`. +4. Restart the gateway. +5. Start a DM with the bot or invite it to a room. - ```bash - curl --request POST \ - --url https://matrix.example.org/_matrix/client/v3/login \ - --header 'Content-Type: application/json' \ - --data '{ - "type": "m.login.password", - "identifier": { - "type": "m.id.user", - "user": "your-user-name" - }, - "password": "your-password" - }' - ``` - - - Replace `matrix.example.org` with your homeserver URL. - - Or set `channels.matrix.userId` + `channels.matrix.password`: OpenClaw calls the same - login endpoint, stores the access token in `~/.openclaw/credentials/matrix/credentials.json`, - and reuses it on next start. - -4. Configure credentials: - - Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`) - - Or config: `channels.matrix.*` - - If both are set, config takes precedence. - - With access token: user ID is fetched automatically via `/whoami`. - - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). -5. Restart the gateway (or finish onboarding). -6. Start a DM with the bot or invite it to a room from any Matrix client - (Element, Beeper, etc.; see [https://matrix.org/ecosystem/clients/](https://matrix.org/ecosystem/clients/)). Beeper requires E2EE, - so set `channels.matrix.encryption: true` and verify the device. - -Minimal config (access token, user ID auto-fetched): +Minimal token-based setup: ```json5 { @@ -85,14 +47,14 @@ Minimal config (access token, user ID auto-fetched): matrix: { enabled: true, homeserver: "https://matrix.example.org", - accessToken: "syt_***", + accessToken: "syt_xxx", dm: { policy: "pairing" }, }, }, } ``` -E2EE config (end to end encryption enabled): +Password-based setup (token is cached after login): ```json5 { @@ -100,7 +62,85 @@ E2EE config (end to end encryption enabled): matrix: { enabled: true, homeserver: "https://matrix.example.org", - accessToken: "syt_***", + userId: "@bot:example.org", + password: "replace-me", + deviceName: "OpenClaw Gateway", + }, + }, +} +``` + +Matrix stores cached credentials in `~/.openclaw/credentials/matrix/`. +The default account uses `credentials.json`; named accounts use `credentials-.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` + +## Configuration example + +This is a practical baseline config with DM pairing, room allowlist, and E2EE enabled: + +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "syt_xxx", + encryption: true, + + dm: { + policy: "pairing", + }, + + groupPolicy: "allowlist", + groupAllowFrom: ["@admin:example.org"], + groups: { + "!roomid:example.org": { + requireMention: true, + }, + }, + + autoJoin: "allowlist", + autoJoinAllowlist: ["!roomid:example.org"], + threadReplies: "inbound", + replyToMode: "off", + }, + }, +} +``` + +## E2EE setup + +Enable encryption: + +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "syt_xxx", encryption: true, dm: { policy: "pairing" }, }, @@ -108,41 +148,193 @@ E2EE config (end to end encryption enabled): } ``` -## Encryption (E2EE) +Check verification status: -End-to-end encryption is **supported** via the Rust crypto SDK. +```bash +openclaw matrix verify status +``` -Enable with `channels.matrix.encryption: true`: +Verbose status (full diagnostics): -- If the crypto module loads, encrypted rooms are decrypted automatically. -- Outbound media is encrypted when sending to encrypted rooms. -- On first connection, OpenClaw requests device verification from your other sessions. -- Verify the device in another Matrix client (Element, etc.) to enable key sharing. -- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt; - OpenClaw logs a warning. -- If you see missing crypto module errors (for example, `@matrix-org/matrix-sdk-crypto-nodejs-*`), - allow build scripts for `@matrix-org/matrix-sdk-crypto-nodejs` and run - `pnpm rebuild @matrix-org/matrix-sdk-crypto-nodejs` or fetch the binary with - `node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js`. +```bash +openclaw matrix verify status --verbose +``` -Crypto state is stored per account + access token in -`~/.openclaw/matrix/accounts//__//crypto/` -(SQLite database). Sync state lives alongside it in `bot-storage.json`. -If the access token (device) changes, a new store is created and the bot must be -re-verified for encrypted rooms. +Bootstrap cross-signing and verification state: -**Device verification:** -When E2EE is enabled, the bot will request verification from your other sessions on startup. -Open Element (or another client) and approve the verification request to establish trust. -Once verified, the bot can decrypt messages in encrypted rooms. +```bash +openclaw matrix verify bootstrap +``` -## Multi-account +Verbose bootstrap diagnostics: -Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. +```bash +openclaw matrix verify bootstrap --verbose +``` -Each account runs as a separate Matrix user on any homeserver. Per-account config -inherits from the top-level `channels.matrix` settings and can override any option -(DM policy, groups, encryption, etc.). +Verify this device with a recovery key: + +```bash +openclaw matrix verify device "" +``` + +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 +``` + +All `verify` commands are concise by default (including quiet internal SDK logging) and show detailed diagnostics only with `--verbose`. +Use `--json` for full machine-readable output when scripting. + +When `encryption: true`, Matrix defaults `startupVerification` to `"if-unverified"`. +On startup, if this device is still unverified, Matrix will request self-verification in another Matrix client, +skip duplicate requests while one is already pending, and apply a local cooldown before retrying after restarts. +Failed request attempts retry sooner than successful request creation by default. +Set `startupVerification: "off"` to disable automatic startup requests, or tune `startupVerificationCooldownHours` +if you want a shorter or longer retry window. + +Upgrading from the previous public Matrix plugin: + +- OpenClaw automatically reuses the same Matrix account, access token, and device identity when possible. +- If the previous plugin stored a Matrix room-key backup decryption key locally, startup or `openclaw doctor --fix` will import it into the new recovery-key flow automatically. +- On the next gateway start, backed-up room keys are restored automatically into the new crypto store. +- If the old plugin had local-only room keys that were never backed up, OpenClaw will warn clearly. Those keys cannot be exported automatically from the previous rust crypto store, so some old encrypted history may remain unavailable until recovered manually. +- See [Matrix migration](/install/migrating-matrix) for the full upgrade flow, limits, recovery commands, and common migration messages. + +Encrypted runtime state is stored per account and per access token in +`~/.openclaw/matrix/accounts//__//`. +That directory contains the sync store (`bot-storage.json`), crypto store (`crypto/`), +recovery key file (`recovery-key.json`), IndexedDB snapshot (`crypto-idb-snapshot.json`), +thread bindings (`thread-bindings.json`), and startup verification state (`startup-verification.json`) +when those features are in use. + +## Automatic verification notices + +Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages. +That includes: + +- verification request notices +- verification ready notices (with explicit "Verify by emoji" guidance) +- verification start and completion notices +- SAS details (emoji and decimal) when available + +Inbound SAS requests are auto-confirmed by the bot device, so once the user confirms "They match" +in their Matrix client, verification completes without requiring a manual OpenClaw tool step. +Verification protocol/system notices are not forwarded to the agent chat pipeline, so they do not produce `NO_REPLY`. + +## Threads + +Matrix supports native Matrix threads for both automatic replies and message-tool sends. + +- `threadReplies: "off"` keeps replies top-level. +- `threadReplies: "inbound"` replies inside a thread only when the inbound message was already in that thread. +- `threadReplies: "always"` keeps room replies in a thread rooted at the triggering message. +- Inbound threaded messages include the thread root message as extra agent context. +- Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided. +- Runtime thread bindings are supported for Matrix. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs. +- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`. +- Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead. + +### Thread Binding Config + +Matrix inherits global defaults from `session.threadBindings`, and also supports per-channel overrides: + +- `threadBindings.enabled` +- `threadBindings.idleHours` +- `threadBindings.maxAgeHours` +- `threadBindings.spawnSubagentSessions` +- `threadBindings.spawnAcpSessions` + +Matrix thread-bound spawn flags are opt-in: + +- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads. +- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads. + +## Reactions + +Matrix supports outbound reaction actions, inbound reaction notifications, and inbound ack reactions. + +- Outbound reaction tooling is gated by `channels["matrix"].actions.reactions`. +- `react` adds a reaction to a specific Matrix event. +- `reactions` lists the current reaction summary for a specific Matrix event. +- `emoji=""` removes the bot account's own reactions on that event. +- `remove: true` removes only the specified emoji reaction from the bot account. + +Ack reactions use the standard OpenClaw resolution order: + +- `channels["matrix"].accounts..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. + +## Multi-account example ```json5 { @@ -152,16 +344,17 @@ inherits from the top-level `channels.matrix` settings and can override any opti dm: { policy: "pairing" }, accounts: { assistant: { - name: "Main assistant", homeserver: "https://matrix.example.org", - accessToken: "syt_assistant_***", + accessToken: "syt_assistant_xxx", encryption: true, }, alerts: { - name: "Alerts bot", homeserver: "https://matrix.example.org", - accessToken: "syt_alerts_***", - dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] }, + accessToken: "syt_alerts_xxx", + dm: { + policy: "allowlist", + allowFrom: ["@ops:example.org"], + }, }, }, }, @@ -169,135 +362,35 @@ inherits from the top-level `channels.matrix` settings and can override any opti } ``` -Notes: +## Configuration reference -- Account startup is serialized to avoid race conditions with concurrent module imports. -- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account. -- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account. -- Use `bindings[].match.accountId` to route each account to a different agent. -- Crypto state is stored per account + access token (separate key stores per account). - -## Routing model - -- Replies always go back to Matrix. -- DMs share the agent's main session; rooms map to group sessions. - -## Access control (DMs) - -- Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code. -- Approve via: - - `openclaw pairing list matrix` - - `openclaw pairing approve matrix ` -- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`. -- `channels.matrix.dm.allowFrom` accepts full Matrix user IDs (example: `@user:server`). The wizard resolves display names to user IDs when directory search finds a single exact match. -- Do not use display names or bare localparts (example: `"Alice"` or `"alice"`). They are ambiguous and are ignored for allowlist matching. Use full `@user:server` IDs. - -## Rooms (groups) - -- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. -- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). -- Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): - -```json5 -{ - channels: { - matrix: { - groupPolicy: "allowlist", - groups: { - "!roomId:example.org": { allow: true }, - "#alias:example.org": { allow: true }, - }, - groupAllowFrom: ["@owner:example.org"], - }, - }, -} -``` - -- `requireMention: false` enables auto-reply in that room. -- `groups."*"` can set defaults for mention gating across rooms. -- `groupAllowFrom` restricts which senders can trigger the bot in rooms (full Matrix user IDs). -- Per-room `users` allowlists can further restrict senders inside a specific room (use full Matrix user IDs). -- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names only on an exact, unique match. -- On startup, OpenClaw resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are ignored for allowlist matching. -- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`. -- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). -- Legacy key: `channels.matrix.rooms` (same shape as `groups`). - -## Threads - -- Reply threading is supported. -- `channels.matrix.threadReplies` controls whether replies stay in threads: - - `off`, `inbound` (default), `always` -- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread: - - `off` (default), `first`, `all` - -## Capabilities - -| Feature | Status | -| --------------- | ------------------------------------------------------------------------------------- | -| Direct messages | ✅ Supported | -| Rooms | ✅ Supported | -| Threads | ✅ Supported | -| Media | ✅ Supported | -| E2EE | ✅ Supported (crypto module required) | -| Reactions | ✅ Supported (send/read via tools) | -| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) | -| Location | ✅ Supported (geo URI; altitude ignored) | -| Native commands | ✅ Supported | - -## Troubleshooting - -Run this ladder first: - -```bash -openclaw status -openclaw gateway status -openclaw logs --follow -openclaw doctor -openclaw channels status --probe -``` - -Then confirm DM pairing state if needed: - -```bash -openclaw pairing list matrix -``` - -Common failures: - -- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist. -- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`. -- Encrypted rooms fail: crypto support or encryption settings mismatch. - -For triage flow: [/channels/troubleshooting](/channels/troubleshooting). - -## Configuration reference (Matrix) - -Full configuration: [Configuration](/gateway/configuration) - -Provider options: - -- `channels.matrix.enabled`: enable/disable channel startup. -- `channels.matrix.homeserver`: homeserver URL. -- `channels.matrix.userId`: Matrix user ID (optional with access token). -- `channels.matrix.accessToken`: access token. -- `channels.matrix.password`: password for login (token stored). -- `channels.matrix.deviceName`: device display name. -- `channels.matrix.encryption`: enable E2EE (default: false). -- `channels.matrix.initialSyncLimit`: initial sync limit. -- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). -- `channels.matrix.textChunkLimit`: outbound text chunk size (chars). -- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. -- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). -- `channels.matrix.dm.allowFrom`: DM allowlist (full Matrix user IDs). `open` requires `"*"`. The wizard resolves names to IDs when possible. -- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). -- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages (full Matrix user IDs). -- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. -- `channels.matrix.groups`: group allowlist + per-room settings map. -- `channels.matrix.rooms`: legacy group allowlist/config. -- `channels.matrix.replyToMode`: reply-to mode for threads/tags. -- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). -- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). -- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join. -- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings). -- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo). +- `enabled`: enable or disable the channel. +- `homeserver`: homeserver URL, for example `https://matrix.example.org`. +- `userId`: full Matrix user ID, for example `@bot:example.org`. +- `accessToken`: access token for token-based auth. +- `password`: password for password-based login. +- `deviceId`: explicit Matrix device ID. +- `deviceName`: device display name for password login. +- `initialSyncLimit`: startup sync event limit. +- `encryption`: enable E2EE. +- `allowlistOnly`: force allowlist-only behavior for DMs and rooms. +- `groupPolicy`: `open`, `allowlist`, or `disabled`. +- `groupAllowFrom`: allowlist of user IDs for room traffic. +- `replyToMode`: `off`, `first`, or `all`. +- `threadReplies`: `off`, `inbound`, or `always`. +- `threadBindings`: per-channel overrides for thread-bound session routing and lifecycle. +- `startupVerification`: automatic self-verification request mode on startup (`if-unverified`, `off`). +- `startupVerificationCooldownHours`: cooldown before retrying automatic startup verification requests. +- `textChunkLimit`: outbound message chunk size. +- `chunkMode`: `length` or `newline`. +- `responsePrefix`: optional message prefix for outbound replies. +- `ackReaction`: optional ack reaction override for this channel/account. +- `ackReactionScope`: optional ack reaction scope override (`group-mentions`, `group-all`, `direct`, `all`, `none`, `off`). +- `reactionNotifications`: inbound reaction notification mode (`own`, `off`). +- `mediaMaxMb`: outbound media size cap in MB. +- `autoJoin`: invite auto-join policy (`always`, `allowlist`, `off`). +- `autoJoinAllowlist`: rooms/aliases allowed when `autoJoin` is `allowlist`. +- `dm`: DM policy block (`enabled`, `policy`, `allowFrom`). +- `groups`: per-room policy map. +- `rooms`: legacy alias for `groups`. +- `actions`: per-action tool gating (`messages`, `reactions`, `pins`, `memberInfo`, `channelInfo`, `verification`). diff --git a/docs/docs.json b/docs/docs.json index e6cf5ba382b..3e99986d22d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -870,7 +870,12 @@ }, { "group": "Maintenance", - "pages": ["install/updating", "install/migrating", "install/uninstall"] + "pages": [ + "install/updating", + "install/migrating", + "install/migrating-matrix", + "install/uninstall" + ] }, { "group": "Hosting and deployment", diff --git a/docs/experiments/plans/matrix-supersession-migration.md b/docs/experiments/plans/matrix-supersession-migration.md index 1154a5d384d..dded21d5e39 100644 --- a/docs/experiments/plans/matrix-supersession-migration.md +++ b/docs/experiments/plans/matrix-supersession-migration.md @@ -1,7 +1,7 @@ --- summary: "Replace the legacy Matrix plugin with the new Matrix implementation while preserving the public matrix surface and providing automatic migration for current users." owner: "gumadeiras" -status: "in progress" +status: "implemented" last_updated: "2026-03-08" title: "Matrix Supersession Migration" --- @@ -28,17 +28,19 @@ This plan is also the working implementation tracker. Update the checklist statu ### Current status - [x] Migration plan written and tracked in repo -- [ ] Replace `extensions/matrix` with the new implementation -- [ ] Remove shipped `matrix-js` public/runtime/package surfaces -- [ ] Preserve legacy `matrix` config compatibility -- [ ] Preserve legacy `matrix` state compatibility -- [ ] Add startup and doctor migration/repair UX -- [ ] Rewrite docs/help/tests to use `matrix` only -- [ ] Verify update flow end to end +- [x] Replace `extensions/matrix` with the new implementation +- [x] Remove shipped `matrix-js` public/runtime/package surfaces +- [x] Preserve legacy `matrix` config compatibility +- [x] Preserve legacy `matrix` state compatibility +- [x] Add startup and doctor migration/repair UX +- [x] Rewrite docs/help/tests to use `matrix` only +- [x] Verify update flow end to end ### Change log - 2026-03-08: Initial supersession plan written and added to docs as a live checklist. +- 2026-03-08: Replaced `extensions/matrix` with the new Matrix implementation, removed shipped `matrix-js` surfaces, added startup/doctor Matrix migration UX, and deleted `extensions/matrix-js`. +- 2026-03-08: Added encrypted-state migration prep for legacy Matrix rust crypto stores, automatic backup-key extraction, startup room-key restore, and explicit warnings for local-only keys that cannot be exported automatically. ## Summary @@ -50,153 +52,156 @@ This plan is also the working implementation tracker. Update the checklist statu ## Public surface after cutover -- [ ] Canonical package/install surface stays `@openclaw/matrix` and `openclaw plugins install @openclaw/matrix`. -- [ ] Canonical channel/plugin/binding id is `matrix`. -- [ ] Canonical config namespace is `channels.matrix`. -- [ ] Canonical CLI surface is `openclaw matrix ...`, including the verification/account commands currently only exposed under Matrix-js. -- [ ] Canonical gateway methods become `matrix.verify.status`, `matrix.verify.bootstrap`, and `matrix.verify.recoveryKey`. -- [ ] Canonical ACP/subagent binding channel is `matrix`. -- [ ] Canonical plugin SDK subpath is `openclaw/plugin-sdk/matrix`. -- [ ] Remove all shipped/public `matrix-js` references from docs, config help, tests, install catalog metadata, and package exports. +- [x] Canonical package/install surface stays `@openclaw/matrix` and `openclaw plugins install @openclaw/matrix`. +- [x] Canonical channel/plugin/binding id is `matrix`. +- [x] Canonical config namespace is `channels.matrix`. +- [x] Canonical CLI surface is `openclaw matrix ...`, including the verification/account commands currently only exposed under Matrix-js. +- [x] Canonical gateway methods become `matrix.verify.status`, `matrix.verify.bootstrap`, and `matrix.verify.recoveryKey`. +- [x] Canonical ACP/subagent binding channel is `matrix`. +- [x] Canonical plugin SDK subpath is `openclaw/plugin-sdk/matrix`. +- [x] Remove all shipped/public `matrix-js` references from docs, config help, tests, install catalog metadata, and package exports. ## Migration flow and UX ### Standard npm users -- [ ] No config key change required. -- [ ] No plugin install record rewrite required because the package remains `@openclaw/matrix`. -- [ ] Updating OpenClaw or running `openclaw plugins update` replaces the plugin in place. -- [ ] Startup and doctor automatically repair any legacy Matrix config/state that the new implementation cannot consume directly. +- [x] No config key change required. +- [x] No plugin install record rewrite required because the package remains `@openclaw/matrix`. +- [x] Updating OpenClaw or running `openclaw plugins update` replaces the plugin in place. +- [x] Startup and doctor automatically repair any legacy Matrix config/state that the new implementation cannot consume directly. ### Startup behavior -- [ ] Keep the existing startup auto-migration model. -- [ ] On first startup after upgrade, detect legacy Matrix config/state mismatches and repair them automatically when the repair is deterministic and local. -- [ ] Log a concise one-time summary of what was migrated and only show next steps when user action is still required. +- [x] Keep the existing startup auto-migration model. +- [x] On first startup after upgrade, detect legacy Matrix config/state mismatches and repair them automatically when the repair is deterministic and local. +- [x] Log a concise one-time summary of what was migrated and only show next steps when user action is still required. ### Doctor and update behavior -- [ ] `openclaw doctor --fix` and update-triggered doctor run the same Matrix migration logic, but with richer user-facing output. -- [ ] Doctor shows exactly which Matrix paths/keys were changed and why. -- [ ] Doctor validates the installed Matrix plugin source and surfaces manual repair steps for custom path installs. +- [x] `openclaw doctor --fix` and update-triggered doctor run the same Matrix migration logic, but with richer user-facing output. +- [x] Doctor shows exactly which Matrix paths/keys were changed and why. +- [x] Doctor validates the installed Matrix plugin source and surfaces manual repair steps for custom path installs. ### Custom or local-path installs -- [ ] Do not auto-rewrite arbitrary custom plugin paths. -- [ ] If the legacy Matrix plugin was installed from a custom path and that path is now stale or missing, warn clearly and print the exact replacement command or path to use. -- [ ] If the custom path is valid and already points at the replacement plugin, leave it alone. +- [x] Do not auto-rewrite arbitrary custom plugin paths. +- [x] If the legacy Matrix plugin was installed from a custom path and that path is now stale or missing, warn clearly and print the exact replacement command or path to use. +- [x] If the custom path is valid and already points at the replacement plugin, leave it alone. ### Unsupported scope -- [ ] No backward compatibility for internal `matrix-js` adopters. -- [ ] Do not auto-migrate `channels.matrix-js`, `@openclaw/matrix-js`, `openclaw matrix-js`, or `plugins.entries["matrix-js"]`. +- [x] No backward compatibility for internal `matrix-js` adopters. +- [x] Do not auto-migrate `channels.matrix-js`, `@openclaw/matrix-js`, `openclaw matrix-js`, or `plugins.entries["matrix-js"]`. ## Implementation changes ### 1. Replace identity at the package and plugin layer -- [ ] Overwrite `extensions/matrix` with the Matrix-js implementation instead of renaming user-facing config. -- [ ] Delete `extensions/matrix-js` after the port is complete. -- [ ] Update `extensions/matrix/package.json`, `extensions/matrix/openclaw.plugin.json`, and `extensions/matrix/index.ts` so the package remains `@openclaw/matrix` but exposes the new feature set. -- [ ] Port the Matrix-js CLI and gateway-method registration into `extensions/matrix/index.ts` and register it under `matrix`, not `matrix-js`. -- [ ] Replace all internal `openclaw/plugin-sdk/matrix-js` imports with `openclaw/plugin-sdk/matrix`. -- [ ] Replace the plugin SDK implementation behind `src/plugin-sdk/matrix.ts` with the Matrix-js helper surface superset, then remove the `matrix-js` plugin-sdk export from `package.json`, `scripts/check-plugin-sdk-exports.mjs`, `scripts/write-plugin-sdk-entry-dts.ts`, and related release/build checks. +- [x] Overwrite `extensions/matrix` with the Matrix-js implementation instead of renaming user-facing config. +- [x] Delete `extensions/matrix-js` after the port is complete. +- [x] Update `extensions/matrix/package.json`, `extensions/matrix/openclaw.plugin.json`, and `extensions/matrix/index.ts` so the package remains `@openclaw/matrix` but exposes the new feature set. +- [x] Port the Matrix-js CLI and gateway-method registration into `extensions/matrix/index.ts` and register it under `matrix`, not `matrix-js`. +- [x] Replace all internal `openclaw/plugin-sdk/matrix-js` imports with `openclaw/plugin-sdk/matrix`. +- [x] Replace the plugin SDK implementation behind `src/plugin-sdk/matrix.ts` with the Matrix-js helper surface superset, then remove the `matrix-js` plugin-sdk export from `package.json`, `scripts/check-plugin-sdk-exports.mjs`, `scripts/write-plugin-sdk-entry-dts.ts`, and related release/build checks. ### 2. Preserve legacy `matrix` config compatibility -- [ ] Make the new `matrix` plugin accept the current public legacy `channels.matrix` schema as-is. -- [ ] Keep support for top-level single-account `channels.matrix.*`. -- [ ] Keep support for `channels.matrix.accounts.*`. -- [ ] Keep support for `channels.matrix.defaultAccount`. -- [ ] Keep support for the legacy `rooms` alias. -- [ ] Keep support for existing DM and group policy keys. -- [ ] Keep support for existing bindings that use `match.channel: "matrix"`. -- [ ] Preserve SecretRef password inputs used by the legacy plugin. -- [ ] Do not require rewriting normal single-account configs into `accounts.default`. -- [ ] Add or keep doctor and startup migrations only for keys that are genuinely obsolete or ignored by the new implementation. -- [ ] Ensure config help, schema labels, and reference docs all describe `channels.matrix`, never `channels.matrix-js`. +- [x] Make the new `matrix` plugin accept the current public legacy `channels.matrix` schema as-is. +- [x] Keep support for top-level single-account `channels.matrix.*`. +- [x] Keep support for `channels.matrix.accounts.*`. +- [x] Keep support for `channels.matrix.defaultAccount`. +- [x] Keep support for the legacy `rooms` alias. +- [x] Keep support for existing DM and group policy keys. +- [x] Keep support for existing bindings that use `match.channel: "matrix"`. +- [x] Preserve SecretRef password inputs used by the legacy plugin. +- [x] Do not require rewriting normal single-account configs into `accounts.default`. +- [x] Add or keep doctor and startup migrations only for keys that are genuinely obsolete or ignored by the new implementation. +- [x] Ensure config help, schema labels, and reference docs all describe `channels.matrix`, never `channels.matrix-js`. ### 3. Preserve legacy `matrix` state and runtime behavior -- [ ] Keep `credentials/matrix/credentials*.json` as the credential root. -- [ ] Keep `matrix/accounts//__//...` as the canonical runtime and crypto root. -- [ ] Add explicit migration support in the new plugin for direct upgrades from the oldest legacy flat store: - - [ ] `~/.openclaw/matrix/bot-storage.json` - - [ ] `~/.openclaw/matrix/crypto/` -- [ ] Do not retain `matrix-js`-path migration logic in the shipped plugin. -- [ ] Preserve multi-account isolation and default-account behavior exactly on the `matrix` channel. -- [ ] Preserve legacy secrets integration by continuing to use the existing `channels.matrix.*` secret collectors and credential surface definitions. -- [ ] Keep route/session binding, ACP binding, thread binding, and outbound message routing keyed to `matrix`, with the current new Matrix functionality carried over. +- [x] Keep `credentials/matrix/credentials*.json` as the credential root. +- [x] Keep `matrix/accounts//__//...` as the canonical runtime and crypto root. +- [x] Add explicit migration support in the new plugin for direct upgrades from the oldest legacy flat store: + - [x] `~/.openclaw/matrix/bot-storage.json` + - [x] `~/.openclaw/matrix/crypto/` +- [x] Do not retain `matrix-js`-path migration logic in the shipped plugin. +- [x] Preserve multi-account isolation and default-account behavior exactly on the `matrix` channel. +- [x] Preserve legacy secrets integration by continuing to use the existing `channels.matrix.*` secret collectors and credential surface definitions. +- [x] Keep route/session binding, ACP binding, thread binding, and outbound message routing keyed to `matrix`, with the current new Matrix functionality carried over. ### 4. Migrate internal Matrix-js-only surfaces to `matrix` -- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across ACP binding schemas and runtime. -- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across thread binding policy and commands. -- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across auto-reply and session context surfaces. -- [ ] Replace every internal channel string and binding string `matrix-js` with `matrix` across agent binding commands and tests. -- [ ] Replace all CLI help, onboarding text, runtime warnings, and verification prompts from `matrix-js` to `matrix`. -- [ ] Rewrite `docs/channels/matrix.md` to describe the new implementation and new verification, ACP, and thread features. -- [ ] Remove `docs/channels/matrix-js.md`. -- [ ] Update shared docs that still reference Matrix-js, including `docs/tools/acp-agents.md`, `docs/tools/subagents.md`, `docs/tools/plugin.md`, and `docs/gateway/configuration-reference.md`. -- [ ] Leave `docs/zh-CN/**` untouched in this pass. +- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across ACP binding schemas and runtime. +- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across thread binding policy and commands. +- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across auto-reply and session context surfaces. +- [x] Replace every internal channel string and binding string `matrix-js` with `matrix` across agent binding commands and tests. +- [x] Replace all CLI help, onboarding text, runtime warnings, and verification prompts from `matrix-js` to `matrix`. +- [x] Rewrite `docs/channels/matrix.md` to describe the new implementation and new verification, ACP, and thread features. +- [x] Remove `docs/channels/matrix-js.md`. +- [x] Update shared docs that still reference Matrix-js, including `docs/tools/acp-agents.md`, `docs/tools/subagents.md`, `docs/tools/plugin.md`, and `docs/gateway/configuration-reference.md`. +- [x] Leave `docs/zh-CN/**` untouched in this pass. ### 5. Automatic messaging and failure handling -- [ ] When startup or doctor rewrites Matrix config, emit a short summary such as: - - [ ] Matrix plugin upgraded in place - - [ ] migrated deprecated Matrix config keys - - [ ] migrated legacy Matrix crypto store - - [ ] no user action required -- [ ] When automatic repair is not safe, emit exact commands, not generic warnings. -- [ ] For custom or stale local plugin paths, point users to the concrete replacement command or path. -- [ ] Never log secrets or token values in migration output. -- [ ] If a legacy state migration fails, continue with clear non-fatal messaging and tell the user what functionality may be degraded until they re-verify. +- [x] When startup or doctor rewrites Matrix config, emit a short summary such as: + - [x] Matrix plugin upgraded in place + - [x] migrated deprecated Matrix config keys + - [x] migrated legacy Matrix crypto store + - [x] no user action required +- [x] When automatic repair is not safe, emit exact commands, not generic warnings. +- [x] For custom or stale local plugin paths, point users to the concrete replacement command or path. +- [x] Never log secrets or token values in migration output. +- [x] If a legacy state migration fails, continue with clear non-fatal messaging and tell the user what functionality may be degraded until they re-verify. ## Test plan and acceptance criteria ### Config compatibility -- [ ] Existing `channels.matrix` single-account config loads unchanged. -- [ ] Existing `channels.matrix.accounts.*` config loads unchanged. -- [ ] Existing `channels.matrix.defaultAccount` behavior is preserved. -- [ ] Existing SecretRef password config continues to validate and resolve. -- [ ] Deprecated Matrix-only keys are auto-repaired by startup and doctor with clear change reporting. +- [x] Existing `channels.matrix` single-account config loads unchanged. +- [x] Existing `channels.matrix.accounts.*` config loads unchanged. +- [x] Existing `channels.matrix.defaultAccount` behavior is preserved. +- [x] Existing SecretRef password config continues to validate and resolve. +- [x] Deprecated Matrix-only keys are auto-repaired by startup and doctor with clear change reporting. ### State compatibility -- [ ] Current canonical `credentials/matrix/*` credentials are reused with no prompt. -- [ ] Current canonical `matrix/accounts/*` runtime state is reused with no prompt. -- [ ] Oldest flat legacy Matrix crypto and sync store is migrated automatically to account-scoped storage. -- [ ] Multi-account state remains isolated after migration. +- [x] Current canonical `credentials/matrix/*` credentials are reused with no prompt. +- [x] Current canonical `matrix/accounts/*` runtime state is reused with no prompt. +- [x] Oldest flat legacy Matrix crypto and sync store is migrated automatically to account-scoped storage. +- [x] Legacy Matrix encrypted backup material is imported automatically when it can be resolved safely. +- [x] Backed-up Matrix room keys are restored automatically on startup after encrypted-state prep. +- [x] Multi-account state remains isolated after migration. ### Plugin and install compatibility -- [ ] Existing npm-installed `@openclaw/matrix` updates in place and remains enabled. -- [ ] `plugins.installs.matrix` continues to update correctly after the cutover. -- [ ] Stale custom path installs are detected and produce exact repair messaging. +- [x] Existing npm-installed `@openclaw/matrix` updates in place and remains enabled. +- [x] `plugins.installs.matrix` continues to update correctly after the cutover. +- [x] Stale custom path installs are detected and produce exact repair messaging. ### Public surface -- [ ] `openclaw matrix ...` exposes the verification and account commands that the new Matrix implementation owns. -- [ ] `matrix.verify.*` gateway methods work. -- [ ] All bindings, ACP, thread, and session flows use `matrix`, not `matrix-js`. -- [ ] No shipped docs, help, schema output, or package exports reference `matrix-js`. +- [x] `openclaw matrix ...` exposes the verification and account commands that the new Matrix implementation owns. +- [x] `matrix.verify.*` gateway methods work. +- [x] All bindings, ACP, thread, and session flows use `matrix`, not `matrix-js`. +- [x] No shipped docs, help, schema output, or package exports reference `matrix-js`. ### Regression coverage -- [ ] Startup auto-migration path. -- [ ] Doctor `--fix` Matrix migration path. -- [ ] Update-triggered doctor path. -- [ ] Route bindings and ACP bindings with `match.channel: "matrix"`. -- [ ] Thread binding spawn gating and routing on `matrix`. -- [ ] Plugin install and update records for `matrix`. +- [x] Startup auto-migration path. +- [x] Doctor `--fix` Matrix migration path. +- [x] Legacy encrypted-state prep and startup restore path. +- [x] Update-triggered doctor path. +- [x] Route bindings and ACP bindings with `match.channel: "matrix"`. +- [x] Thread binding spawn gating and routing on `matrix`. +- [x] Plugin install and update records for `matrix`. ### Acceptance criteria -- [ ] A current public Matrix user can update and keep using `channels.matrix` without editing config. -- [ ] Automatic migration covers every deterministic case. -- [ ] Every non-deterministic case produces explicit next steps. -- [ ] No public `matrix-js` surface remains in the shipped product. +- [x] A current public Matrix user can update and keep using `channels.matrix` without editing config. +- [x] Automatic migration covers every deterministic case. +- [x] Every non-deterministic case produces explicit next steps. +- [x] No public `matrix-js` surface remains in the shipped product. ## Assumptions and defaults diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 7d719007325..752ae47d735 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -651,7 +651,7 @@ Run multiple accounts per channel (each with its own `accountId`): ### Other extension channels Many extension channels are configured as `channels.` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch). -Matrix-js also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`. +Matrix also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`. See the full channel index: [Channels](/channels). ### Group chat mention gating diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md new file mode 100644 index 00000000000..e0901bd1a7c --- /dev/null +++ b/docs/install/migrating-matrix.md @@ -0,0 +1,243 @@ +--- +summary: "How OpenClaw upgrades the previous Matrix plugin in place, including encrypted-state recovery limits and manual recovery steps." +read_when: + - Upgrading an existing Matrix installation + - Migrating encrypted Matrix history and device state +title: "Matrix migration" +--- + +# Matrix migration + +This page covers upgrades from the previous public `matrix` plugin to the current implementation. + +For most users, the upgrade is in place: + +- the plugin stays `@openclaw/matrix` +- the channel stays `matrix` +- your config stays under `channels.matrix` +- cached credentials stay under `~/.openclaw/credentials/matrix/` +- runtime state stays under `~/.openclaw/matrix/` + +You do not need to rename config keys or reinstall the plugin under a new name. + +## What the migration does automatically + +When the gateway starts, and when you run [`openclaw doctor --fix`](/gateway/doctor), OpenClaw tries to repair old Matrix state automatically. + +Automatic migration covers: + +- reusing your cached Matrix credentials +- keeping the same account selection and `channels.matrix` config +- moving the oldest flat Matrix sync store into the current account-scoped location +- moving the oldest flat Matrix crypto store into the current account-scoped location when the target account can be resolved safely +- extracting a previously saved Matrix room-key backup decryption key from the old rust crypto store, when that key exists locally +- restoring backed-up room keys into the new crypto store on the next Matrix startup + +## What the migration cannot do automatically + +The previous public Matrix plugin did **not** automatically create Matrix room-key backups. It persisted local crypto state and requested device verification, but it did not guarantee that your room keys were backed up to the homeserver. + +That means some encrypted installs can only be migrated partially. + +OpenClaw cannot automatically recover: + +- local-only room keys that were never backed up +- encrypted state when the target Matrix account cannot be resolved yet because `homeserver`, `userId`, or `accessToken` are still unavailable +- custom plugin path installs that now point at a missing directory +- a missing recovery key when the old store had backed-up keys but did not keep the decryption key locally + +If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade. + +## Recommended upgrade flow + +1. Update OpenClaw and the Matrix plugin normally. +2. Run: + + ```bash + openclaw doctor --fix + ``` + +3. Start or restart the gateway. +4. Check current verification and backup state: + + ```bash + openclaw matrix verify status + openclaw matrix verify backup status + ``` + +5. If OpenClaw tells you a recovery key is needed, run: + + ```bash + openclaw matrix verify backup restore --recovery-key "" + ``` + +6. If this device is still unverified, run: + + ```bash + openclaw matrix verify device "" + ``` + +7. If no server-side key backup exists yet, create one for future recoveries: + + ```bash + openclaw matrix verify bootstrap + ``` + +## How encrypted migration works + +Encrypted migration is a two-stage process: + +1. Startup or `openclaw doctor --fix` inspects the old Matrix crypto store. +2. If a backup decryption key is found, OpenClaw writes it into the new recovery-key flow and marks room-key restore as pending. +3. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically. + +If the old store reports room keys that were never backed up, OpenClaw warns instead of pretending recovery succeeded. + +## Common messages and what they mean + +### Upgrade and detection messages + +`Matrix plugin upgraded in place.` + +- Meaning: the old on-disk Matrix state was detected and migrated into the current layout. +- What to do: nothing unless the same output also includes warnings. + +`Legacy Matrix state detected at ... but channels.matrix is not configured yet.` + +- Meaning: old Matrix state exists, but OpenClaw cannot map it to a current Matrix account because Matrix is not configured. +- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway. + +`Legacy Matrix state detected at ... but the new account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).` + +- Meaning: OpenClaw found old state, but it still cannot determine the exact current account/device root. +- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials exist. + +`Matrix legacy sync store not migrated because the target already exists (...)` + +- Meaning: the new account-scoped location already has a sync or crypto store, so OpenClaw did not overwrite it automatically. +- What to do: verify that the current account is the correct one before manually removing or moving the conflicting target. + +`Failed migrating Matrix legacy sync store (...)` or `Failed migrating Matrix legacy crypto store (...)` + +- Meaning: OpenClaw tried to move old Matrix state but the filesystem operation failed. +- What to do: inspect filesystem permissions and disk state, then rerun `openclaw doctor --fix`. + +`Legacy Matrix encrypted state detected at ... but channels.matrix is not configured yet.` + +- Meaning: OpenClaw found an old encrypted Matrix store, but there is no current Matrix config to attach it to. +- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway. + +`Legacy Matrix encrypted state detected at ... but the account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).` + +- Meaning: the encrypted store exists, but OpenClaw cannot safely decide which current account/device it belongs to. +- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials are available. + +### Encrypted-state recovery messages + +`matrix: restored X/Y room key(s) from legacy encrypted-state backup` + +- Meaning: backed-up room keys were restored successfully into the new crypto store. +- What to do: usually nothing. + +`matrix: N legacy local-only room key(s) were never backed up and could not be restored automatically` + +- Meaning: some old room keys existed only in the old local store and had never been uploaded to Matrix backup. +- What to do: expect some old encrypted history to remain unavailable unless you can recover those keys manually from another verified client. + +`Legacy Matrix encrypted state for account "..." has backed-up room keys, but no local backup decryption key was found. Ask the operator to run "openclaw matrix verify backup restore --recovery-key " 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. + +`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. + +## Related pages + +- [Matrix](/channels/matrix) +- [Doctor](/gateway/doctor) +- [Migrating](/install/migrating) +- [Plugins](/tools/plugin) diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 0e80c7a6216..da0db594f91 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -79,7 +79,7 @@ Required feature flags for thread-bound ACP: - `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch) - Channel-adapter ACP thread-spawn flag enabled (adapter-specific) - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` - - Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` + - Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true` - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ### Thread supporting channels @@ -87,7 +87,7 @@ Required feature flags for thread-bound ACP: - Any channel adapter that exposes session/thread binding capability. - Current built-in support: - Discord threads/channels - - Matrix-js room threads and DMs + - Matrix room threads and DMs - Telegram topics (forum topics in groups/supergroups and DM topics) - Plugin channels can add support through the same binding interface. @@ -100,7 +100,7 @@ For non-ephemeral workflows, configure persistent ACP bindings in top-level `bin - `bindings[].type="acp"` marks a persistent ACP conversation binding. - `bindings[].match` identifies the target conversation: - Discord channel or thread: `match.channel="discord"` + `match.peer.id=""` - - Matrix room or thread: `match.channel="matrix-js"` + `match.peer.id=""` + - Matrix room or thread: `match.channel="matrix"` + `match.peer.id=""` - Telegram forum topic: `match.channel="telegram"` + `match.peer.id=":topic:"` - `bindings[].agentId` is the owning OpenClaw agent id. - Optional ACP overrides live under `bindings[].acp`: @@ -378,7 +378,7 @@ Notes: - On non-thread binding surfaces, default behavior is effectively `off`. - Thread-bound spawn requires channel policy support: - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` - - Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` + - Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true` - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ## ACP controls @@ -479,7 +479,7 @@ Core ACP baseline: } ``` -Thread binding config is channel-adapter specific. Example for Discord and Matrix-js: +Thread binding config is channel-adapter specific. Example for Discord and Matrix: ```json5 { @@ -497,7 +497,7 @@ Thread binding config is channel-adapter specific. Example for Discord and Matri spawnAcpSessions: true, }, }, - "matrix-js": { + matrix: { threadBindings: { enabled: true, spawnAcpSessions: true, @@ -510,7 +510,7 @@ Thread binding config is channel-adapter specific. Example for Discord and Matri If thread-bound ACP spawn does not work, verify the adapter feature flag first: - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` -- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` +- Matrix: `channels["matrix"].threadBindings.spawnAcpSessions=true` See [Configuration Reference](/gateway/configuration-reference). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 88c39797f00..2dfab7554d3 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -166,7 +166,7 @@ authoring plugins: `openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`, `openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`, `openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`, - `openclaw/plugin-sdk/matrix-js`, + `openclaw/plugin-sdk/matrix`, `openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`, `openclaw/plugin-sdk/memory-lancedb`, `openclaw/plugin-sdk/minimax-portal-auth`, diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index d281714c6cc..7cfa3385849 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -17,7 +17,7 @@ Channel notes: - **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji. - **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji. -- **Matrix-js**: empty `emoji` removes the bot account's own reactions on the message; `remove: true` removes just that emoji; inbound reaction notifications on bot-authored messages are controlled by `reactionNotifications`. +- **Matrix**: empty `emoji` removes the bot account's own reactions on the message; `remove: true` removes just that emoji; inbound reaction notifications on bot-authored messages are controlled by `reactionNotifications`. - **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation. - **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`). - **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction. diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index dce15f41d8d..01295c576c9 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -100,7 +100,7 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a ### Thread supporting channels - `sessions_spawn` with `thread: true`: currently supported on Discord only. -- Manual thread/conversation controls are supported on Discord, Matrix-js, and Telegram. +- Manual thread/conversation controls are supported on Discord, Matrix, and Telegram. - Available controls: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age` Quick flow: diff --git a/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md b/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md deleted file mode 100644 index 20c665f306b..00000000000 --- a/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md +++ /dev/null @@ -1,120 +0,0 @@ -# Legacy Matrix Parity Gap Audit - -Audit date: February 23, 2026 - -Scope: - -- Baseline spec: `/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md` -- Compared implementations: - - Legacy: `/extensions/matrix` - - New: `/extensions/matrix-js` - -Method: - -- Static code comparison and targeted file inspection. -- Runtime validation executed for matrix-js test suites and project build. - -Status legend: - -- `PASS (static)` = code-level parity confirmed. -- `NEEDS UPDATING` = concrete parity/coexistence gap found. -- `UNVERIFIED (runtime)` = requires executing tests/integration flows. - -## Summary - -- Overall feature parity with legacy behavior: strong at code level. -- Previously identified dual-plugin coexistence blockers are resolved in code. -- Matrix-js regression tests pass (`27` files, `112` tests). -- Full repository build passes after the matrix-js namespace/storage changes. -- Remaining runtime validation gap: explicit side-by-side legacy `matrix` + `matrix-js` integration run. - -## Coexistence Gaps (Current Status) - -1. `PASS (static)`: Channel identity is consistent as `matrix-js` across metadata and runtime registration. - -- Evidence: - - `/extensions/matrix-js/index.ts:7` - - `/extensions/matrix-js/openclaw.plugin.json:2` - - `/extensions/matrix-js/src/channel.ts:41` - - `/extensions/matrix-js/src/channel.ts:99` - -2. `PASS (static)`: Config namespace is consistently `channels.matrix-js`. - -- Evidence: - - `/extensions/matrix-js/src/channel.ts:116` - - `/extensions/matrix-js/src/channel.ts:125` - - `/extensions/matrix-js/src/channel.ts:319` - - `/extensions/matrix-js/src/onboarding.ts:17` - - `/extensions/matrix-js/src/onboarding.ts:174` - - `/extensions/matrix-js/src/matrix/send/client.ts:22` - - `/extensions/matrix-js/src/matrix/client/config.ts:125` - -3. `PASS (static)`: Outbound/inbound channel tags and routing context emit `matrix-js`. - -- Evidence: - - `/extensions/matrix-js/src/outbound.ts:20` - - `/extensions/matrix-js/src/outbound.ts:36` - - `/extensions/matrix-js/src/outbound.ts:49` - - `/extensions/matrix-js/src/matrix/send.ts:55` - - `/extensions/matrix-js/src/matrix/monitor/handler.ts:496` - - `/extensions/matrix-js/src/matrix/monitor/handler.ts:509` - -4. `PASS (static)`: Matrix-js now uses isolated storage namespace/prefixes. - -- Evidence: - - `/extensions/matrix-js/src/matrix/credentials.ts:31` - - `/extensions/matrix-js/src/matrix/client/storage.ts:42` - - `/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts:127` - - `/extensions/matrix-js/src/matrix/client/create-client.ts:43` - -## Parity Matrix (Spec Section 16, Pre-Filled) - -| Check | Status | Evidence | -| ---------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Config schema keys and defaults are equivalent | PASS (static) | `/extensions/matrix/src/config-schema.ts` vs `/extensions/matrix-js/src/config-schema.ts` (no semantic diffs) | -| Auth precedence (config/env/token/cache/password/register) matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/client/config.ts` | -| Bun runtime rejection behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/client/runtime.ts`, `/extensions/matrix-js/src/matrix/monitor/index.ts` | -| Startup/shutdown lifecycle and status updates match legacy | PASS (static) | `/extensions/matrix-js/src/channel.ts`, `/extensions/matrix-js/src/matrix/monitor/index.ts` | -| DM detection heuristics match legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/direct.ts` | -| DM/group allowlist + pairing flow matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/extensions/matrix-js/src/matrix/monitor/allowlist.ts` | -| Mention detection (`m.mentions`, formatted_body links, regex) matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/mentions.ts` | -| Control-command authorization gate behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/handler.ts` | -| Inbound poll normalization matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/poll-types.ts`, `/extensions/matrix-js/src/matrix/monitor/handler.ts` | -| Inbound location normalization matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/location.ts` | -| Inbound media download/decrypt/size-limit behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/media.ts` | -| Reply dispatch + typing + ack reaction + read receipts match legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/handler.ts`, `/extensions/matrix-js/src/matrix/monitor/replies.ts` | -| Thread handling (`threadReplies`) matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/threads.ts` | -| `replyToMode` handling for single/multi reply flows matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/monitor/replies.ts` | -| Outbound text chunking, markdown, and formatting behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/send.ts`, `/extensions/matrix-js/src/matrix/send/formatting.ts` | -| Outbound media encryption/voice/thumbnail/duration behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/send/media.ts` | -| Outbound poll payload behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/send.ts`, `/extensions/matrix-js/src/matrix/poll-types.ts` | -| Action gating and action semantics match legacy | PASS (static) | `/extensions/matrix-js/src/actions.ts`, `/extensions/matrix-js/src/tool-actions.ts`, `/extensions/matrix-js/src/matrix/actions/*` | -| Verification action flow and summary semantics match legacy | PASS (static) | `/extensions/matrix-js/src/matrix/actions/verification.ts`, `/extensions/matrix-js/src/matrix/sdk/verification-manager.ts`, `/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts` | -| Directory live lookup + target resolution ambiguity handling matches legacy | PASS (static) | `/extensions/matrix-js/src/directory-live.ts`, `/extensions/matrix-js/src/resolve-targets.ts` | -| Probe/status reporting fields match legacy | PASS (static) | `/extensions/matrix-js/src/matrix/probe.ts`, `/extensions/matrix-js/src/channel.ts` | -| Storage layout and credential persistence semantics match legacy | PASS (static) | `/extensions/matrix-js/src/matrix/client/storage.ts`, `/extensions/matrix-js/src/matrix/credentials.ts` | -| HTTP hardening and decrypt retry behavior matches legacy | PASS (static) | `/extensions/matrix-js/src/matrix/sdk/http-client.ts`, `/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts`, `/extensions/matrix-js/src/matrix/sdk.ts` | - -## Runtime Validation Status - -- `PASS (runtime)`: matrix-js regression run succeeded via `pnpm test extensions/matrix-js/src` (`27` files, `112` tests). -- `PASS (runtime)`: build/type pipeline succeeded via `pnpm build`. -- `UNVERIFIED (runtime)`: side-by-side load of legacy `matrix` plus `matrix-js` with independent config. - -Recommended commands for final coexistence sign-off: - -```bash -pnpm test extensions/matrix/src -pnpm test extensions/matrix-js/src -pnpm build -``` - -## Suggested Next Fix Batch - -1. Add explicit coexistence integration tests: - -- Load both legacy `matrix` and `matrix-js` in one runtime with independent config + pairing state. - -2. Validate state migration behavior (if required by product decision): - -- Decide whether `matrix-js` should intentionally read legacy `channels.matrix`/`credentials/matrix` during transition or stay fully isolated. diff --git a/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md b/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md deleted file mode 100644 index 75b1ff25a7b..00000000000 --- a/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md +++ /dev/null @@ -1,397 +0,0 @@ -# Legacy Matrix Plugin Parity Spec - -This document defines the expected behavior of the **legacy Matrix plugin** (`extensions/matrix`) so the new **matrix-js plugin** (`extensions/matrix-js`) can be verified for feature parity. - -## 1. Scope - -- Legacy source of truth: - - `extensions/matrix/index.ts` - - `extensions/matrix/src/channel.ts` - - `extensions/matrix/src/**/*.ts` -- New implementation under test: - - `extensions/matrix-js/**` -- Goal: matrix-js should preserve user-visible and operator-visible behavior unless explicitly changed. - -## 2. Parity Levels - -- `MUST`: required parity for GA. -- `SHOULD`: desirable parity; acceptable temporary delta if documented. -- `NICE`: optional parity. - -## 3. Channel + Plugin Contract (MUST) - -- Plugin id remains `matrix`; channel id exposed to runtime is `matrix` in legacy. -- Channel metadata parity: - - label/selection/docs path/blurb/order/quickstart allowFrom behavior. -- Channel capabilities parity: - - `chatTypes`: direct, group, thread - - `polls`: true - - `reactions`: true - - `threads`: true - - `media`: true -- Reload behavior parity: - - config prefixes include `channels.matrix`. -- Pairing behavior parity: - - pairing id label, allow-entry normalization, approval notification message behavior. - -## 4. Configuration Contract (MUST) - -Legacy schema lives in `extensions/matrix/src/config-schema.ts` and `extensions/matrix/src/types.ts`. - -### 4.1 Core fields - -- `enabled?: boolean` -- Auth: `homeserver`, `userId`, `accessToken`, `password`, `register`, `deviceId`, `deviceName` -- Sync/runtime: `initialSyncLimit`, `encryption` -- Access control: - - `allowlistOnly` - - `groupPolicy`: `open|allowlist|disabled` - - `groupAllowFrom` - - `dm.policy`: `pairing|allowlist|open|disabled` - - `dm.allowFrom` -- Room policy: - - `groups` (preferred) and `rooms` (legacy alias) - - room fields: `enabled`, `allow`, `requireMention`, `tools`, `autoReply`, `users`, `skills`, `systemPrompt` -- Reply/thread behavior: - - `replyToMode`: `off|first|all` - - `threadReplies`: `off|inbound|always` -- Output shaping: - - `markdown`, `textChunkLimit`, `chunkMode`, `responsePrefix` -- Media + invites: - - `mediaMaxMb` - - `autoJoin`: `always|allowlist|off` - - `autoJoinAllowlist` -- Action gates: - - `actions.reactions|messages|pins|memberInfo|channelInfo|verification` - -### 4.2 Defaults and effective behavior - -- DM default policy: `pairing`. -- Group mention default: mention required in rooms unless room override allows auto-reply. -- `replyToMode` default: `off`. -- `threadReplies` default: `inbound`. -- `autoJoin` default: `always`. -- Legacy global hard text max remains 4000 chars per chunk for matrix sends/replies. -- When `allowlistOnly=true`, policies are effectively tightened: - - group `open` behaves as `allowlist` - - DM policy behaves as `allowlist` unless explicitly disabled. - -## 5. Account Model + Resolution (MUST) - -- Account listing/resolution behavior in `extensions/matrix/src/matrix/accounts.ts`: - - supports top-level single account fallback (`default` account semantics). - - supports per-account map and normalized account IDs. - - per-account config deep-merges known nested sections (`dm`, `actions`) over base config. -- Account configured state logic parity: - - configured when homeserver exists and one of: - - access token - - userId+password - - matching stored credentials. - -## 6. Auth + Client Bootstrap (MUST) - -Legacy auth behavior in `extensions/matrix/src/matrix/client/config.ts`: - -- Config/env resolution precedence: - - config values override env values. - - env vars: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN`, `MATRIX_PASSWORD`, `MATRIX_REGISTER`, `MATRIX_DEVICE_ID`, `MATRIX_DEVICE_NAME`. -- Token-first behavior: - - with access token, `whoami` resolves missing `userId` and/or `deviceId`. -- Credential cache behavior: - - reuses cached credentials when config matches homeserver+user (or homeserver-only token flow). - - updates `lastUsedAt` when reused. -- Password login behavior: - - login with `m.login.password` when no token. -- Register mode behavior: - - if login fails and `register=true`, attempts registration and then login-equivalent token flow. - - registration mode prepares backup snapshot and finalizes config by turning off `register` and removing stale inline token. -- Bun runtime must be rejected (Node required). - -## 7. Runtime/Connection Lifecycle (MUST) - -- Gateway startup path (`channel.ts` + `monitor/index.ts`) must: - - resolve auth, - - resolve shared client, - - attach monitor handlers, - - start sync, - - report runtime status fields. -- Shutdown behavior: - - client is stopped on abort, - - active client reference cleared. -- Startup lock behavior: - - startup import race is serialized via lock in `channel.ts`. - -## 8. Inbound Event Processing (MUST) - -Legacy handler logic: `extensions/matrix/src/matrix/monitor/handler.ts`. - -### 8.1 Event eligibility - -- Processes: - - `m.room.message` - - poll start events (`m.poll.start` + MSC aliases) - - location events (`m.location` and location msgtype) -- Ignores: - - redacted events - - self-sent events - - old pre-startup events - - edit relation events (`m.replace`) - - encrypted raw payloads (expects decrypted bridge events) - -### 8.2 DM/group detection - -- DM detection chain (`monitor/direct.ts`): - - `m.direct` cache, - - member-count heuristic (2 users), - - `is_direct` member-state fallback. - -### 8.3 Access control + allowlists - -- DM policy behavior: - - `disabled`: no DM processing. - - `open`: process all DMs. - - `allowlist`: process only matching allowlist. - - `pairing`: create pairing request/code for unauthorized sender and send approval instructions. -- Group policy behavior: - - `disabled`: ignore rooms. - - `allowlist`: room must exist in allowlisted rooms map (or wildcard) and pass optional sender constraints. - - `open`: allow rooms, still mention-gated by default. -- Group sender gating: - - room-level `users` allowlist if configured. - - `groupAllowFrom` fallback when room users list not set. - -### 8.4 Mention + command gate behavior - -- Mention detection parity: - - `m.mentions.user_ids` - - `m.mentions.room` - - `formatted_body` matrix.to links (plain and URL-encoded) - - mention regex patterns from core mention config -- Default room behavior requires mention unless room policy overrides. -- Control command bypass behavior: - - unauthorized control commands are dropped in group contexts. - -### 8.5 Input normalization - -- Poll start events converted to normalized text payload. -- Location events converted to normalized location text + context fields. -- mxc media downloaded (and decrypted when file payload present) with max-byte enforcement. - -### 8.6 Context/session/routing - -- Builds context with matrix-specific fields: - - From/To/SessionKey/MessageSid/ReplyToId/MessageThreadId/MediaPath/etc. -- Resolves per-agent route via core routing. -- Persists inbound session metadata and updates last-route for DM contexts. - -### 8.7 Reply delivery - -- Typing indicators start/stop around reply dispatch. -- Reply prefix/model-selection behavior uses core reply options. -- Room-level `skills` filter and `systemPrompt` are applied. -- Reply delivery semantics: - - `replyToMode` controls how often replyTo is used (`off|first|all`). - - thread target suppresses plain replyTo fallback. - - chunking and markdown-table conversion parity required. - -### 8.8 Side effects - -- Optional ack reaction based on `messages.ackReaction` + scope rules. -- Read receipt sent for inbound event IDs. -- System event enqueued after successful reply. - -## 9. Outbound Sending Contract (MUST) - -Legacy send behavior: `extensions/matrix/src/matrix/send.ts` and `send/*`. - -### 9.1 Text - -- Requires text or media; empty text without media is error. -- Resolves target IDs from `matrix:/room:/channel:/user:/@user/#alias` forms. -- Markdown tables converted via core table mode. -- Markdown converted to Matrix HTML formatting. -- Chunking respects configured limit but hard-caps at 4000. -- Thread relation behavior: - - `threadId` -> `m.thread` relation. - - otherwise optional reply relation. - -### 9.2 Media - -- Loads media via core media loader with size limits. -- Upload behavior: - - encrypts media in encrypted rooms when crypto available. - - otherwise plain upload. -- Includes metadata: - - mimetype/size/duration, - - image dimensions/thumbnail when available. -- Voice behavior: - - if `audioAsVoice=true` and compatible audio, send as voice payload (`org.matrix.msc3245.voice`). -- Caption/follow-up behavior: - - first chunk is caption, - - remaining text chunks become follow-up messages. - -### 9.3 Polls - -- Supports `sendPoll` with MSC3381 payload (`m.poll.start`) + fallback text. -- Supports thread relation for polls when thread ID present. - -### 9.4 Reactions + receipts + typing - -- Supports sending reactions (`m.reaction` annotation). -- Supports typing state and read receipts. - -## 10. Tool/Action Contract (MUST) - -Legacy action adapter: `src/actions.ts`, `src/tool-actions.ts`, `src/matrix/actions/*`. - -### 10.1 Action availability gates - -- Baseline actions include `send` and poll path support. -- Optional gated actions: - - reactions: `react`, `reactions` - - messages: `read`, `edit`, `delete` - - pins: `pin`, `unpin`, `list-pins` - - member info: `member-info` - - channel info: `channel-info` - - verification: `permissions` (only with encryption enabled + gate enabled) - -### 10.2 Action semantics - -- Send/edit/delete/read messages behavior parity: - - edit uses `m.replace` + `m.new_content` conventions. - - read uses `/rooms/{room}/messages` with before/after pagination tokens. -- Reaction semantics parity: - - list aggregates count per emoji and unique users. - - remove only current-user reactions (optional emoji filter). -- Pin semantics parity: - - state event `m.room.pinned_events` update/read. - - list includes resolvable summarized events. -- Member info semantics parity: - - profile display name/avatar available, - - membership/power currently returned as `null` placeholders. -- Room info semantics parity: - - includes name/topic/canonicalAlias/memberCount where retrievable. -- Verification semantics parity: - - status/list/request/accept/cancel/start/generate-qr/scan-qr/sas/confirm/mismatch/confirm-qr flows. - -## 11. Directory + Target Resolution (MUST) - -### 11.1 Live directory - -- Peer lookup uses Matrix user directory search endpoint. -- Group lookup behavior: - - alias input (`#...`) resolves via directory API, - - room ID input (`!...`) is accepted directly, - - otherwise scans joined rooms by room name. - -### 11.2 Resolver behavior - -- User resolver rules: - - full user IDs resolve directly, - - otherwise requires exact unique match from live directory. -- Group resolver rules: - - prefers exact match; otherwise first candidate with note. -- Room config key normalization behavior: - - supports `matrix:`/`room:`/`channel:` prefixes and canonical IDs. - -## 12. Status + Probing (MUST) - -- Probe behavior (`matrix/probe.ts`): - - validates homeserver + token, - - initializes client, - - resolves user via client and returns elapsed time/status. -- Channel status snapshot includes: - - configured/baseUrl/running/last start-stop/error/probe/last probe/inbound/outbound fields. - -## 13. Storage + Security + E2EE (MUST) - -### 13.1 Credential/state paths - -- Credentials persisted in state dir under `credentials/matrix`. -- Per-account credential filename semantics preserved. -- Matrix storage paths include account key + homeserver key + user key + token hash. -- Legacy storage migration behavior preserved. - -### 13.2 HTTP hardening - -- Matrix HTTP client behavior parity: - - blocks unexpected absolute endpoints, - - blocks cross-protocol redirects, - - strips auth headers on cross-origin redirect, - - supports request timeout. - -### 13.3 Encryption - -- Rust crypto initialization and bootstrap behavior preserved. -- Decryption bridge behavior preserved: - - encrypted event handling, - - failed decrypt retries, - - retry caps and signal-driven retry. -- Recovery key behavior preserved: - - persisted securely (0600), - - reused for secret storage callbacks, - - handles default key rebind and recreation when needed. - -## 14. Onboarding UX Contract (SHOULD) - -Legacy onboarding (`src/onboarding.ts`) should remain equivalent: - -- checks matrix SDK availability and offers install flow, -- supports env-detected quick setup, -- supports token/password/register auth choice, -- validates homeserver URL and user ID format, -- supports DM policy and allowFrom prompt with user resolution, -- supports optional group policy and group room selection. - -## 15. Known Legacy Quirks To Track (NEEDS UPDATING) - -These should be explicitly reviewed during parity auditing (either preserve intentionally or fix intentionally): - -- `supportsAction`/`poll` behavior in action adapter is non-obvious and should be validated end-to-end. -- Some account-aware callsites pass `accountId` through paths where underlying helpers may not consistently consume it. -- Legacy room/member info actions include placeholder/null fields (`altAliases`, `membership`, `powerLevel`). - -## 16. Parity Test Matrix - -Use this checklist while validating `extensions/matrix-js`: - -- [ ] Config schema keys and defaults are equivalent. -- [ ] Auth precedence (config/env/token/cache/password/register) matches legacy. -- [ ] Bun runtime rejection behavior matches legacy. -- [ ] Startup/shutdown lifecycle and status updates match legacy. -- [ ] DM detection heuristics match legacy. -- [ ] DM/group allowlist + pairing flow matches legacy. -- [ ] Mention detection (`m.mentions`, formatted_body links, regex) matches legacy. -- [ ] Control-command authorization gate behavior matches legacy. -- [ ] Inbound poll normalization matches legacy. -- [ ] Inbound location normalization matches legacy. -- [ ] Inbound media download/decrypt/size-limit behavior matches legacy. -- [ ] Reply dispatch + typing + ack reaction + read receipts match legacy. -- [ ] Thread handling (`threadReplies`) matches legacy. -- [ ] `replyToMode` handling for single/multi reply flows matches legacy. -- [ ] Outbound text chunking, markdown, and formatting behavior matches legacy. -- [ ] Outbound media encryption/voice/thumbnail/duration behavior matches legacy. -- [ ] Outbound poll payload behavior matches legacy. -- [ ] Action gating and action semantics match legacy. -- [ ] Verification action flow and summary semantics match legacy. -- [ ] Directory live lookup + target resolution ambiguity handling matches legacy. -- [ ] Probe/status reporting fields match legacy. -- [ ] Storage layout and credential persistence semantics match legacy. -- [ ] HTTP hardening and decrypt retry behavior matches legacy. - -## 17. Minimum Regression Commands - -Run at least: - -```bash -pnpm vitest extensions/matrix/src/**/*.test.ts -pnpm vitest extensions/matrix-js/src/**/*.test.ts -pnpm build -``` - -If behavior differs intentionally, document the delta under this spec with: - -- reason, -- user impact, -- migration note, -- tests proving new intended behavior. diff --git a/extensions/matrix-js/index.ts b/extensions/matrix-js/index.ts deleted file mode 100644 index 23d8b53c1e1..00000000000 --- a/extensions/matrix-js/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { - GatewayRequestHandlerOptions, - OpenClawPluginApi, -} from "openclaw/plugin-sdk/matrix-js"; -import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix-js"; -import { matrixPlugin } from "./src/channel.js"; -import { registerMatrixJsCli } from "./src/cli.js"; -import { - bootstrapMatrixVerification, - getMatrixVerificationStatus, - verifyMatrixRecoveryKey, -} from "./src/matrix/actions/verification.js"; -import { setMatrixRuntime } from "./src/runtime.js"; - -const plugin = { - id: "matrix-js", - name: "Matrix-js", - description: "Matrix channel plugin (matrix-js-sdk)", - configSchema: emptyPluginConfigSchema(), - register(api: OpenClawPluginApi) { - setMatrixRuntime(api.runtime); - api.registerChannel({ plugin: matrixPlugin }); - - const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => { - respond(false, { error: err instanceof Error ? err.message : String(err) }); - }; - - api.registerGatewayMethod( - "matrix-js.verify.recoveryKey", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const key = typeof params?.key === "string" ? params.key : ""; - if (!key.trim()) { - respond(false, { error: "key required" }); - return; - } - const accountId = - typeof params?.accountId === "string" - ? params.accountId.trim() || undefined - : undefined; - const result = await verifyMatrixRecoveryKey(key, { accountId }); - respond(result.success, result); - } catch (err) { - sendError(respond, err); - } - }, - ); - - api.registerGatewayMethod( - "matrix-js.verify.bootstrap", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const accountId = - typeof params?.accountId === "string" - ? params.accountId.trim() || undefined - : undefined; - const recoveryKey = - typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined; - const forceResetCrossSigning = params?.forceResetCrossSigning === true; - const result = await bootstrapMatrixVerification({ - accountId, - recoveryKey, - forceResetCrossSigning, - }); - respond(result.success, result); - } catch (err) { - sendError(respond, err); - } - }, - ); - - api.registerGatewayMethod( - "matrix-js.verify.status", - async ({ params, respond }: GatewayRequestHandlerOptions) => { - try { - const accountId = - typeof params?.accountId === "string" - ? params.accountId.trim() || undefined - : undefined; - const includeRecoveryKey = params?.includeRecoveryKey === true; - const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey }); - respond(true, status); - } catch (err) { - sendError(respond, err); - } - }, - ); - - api.registerCli( - ({ program }) => { - registerMatrixJsCli({ program }); - }, - { commands: ["matrix-js"] }, - ); - }, -}; - -export default plugin; diff --git a/extensions/matrix-js/openclaw.plugin.json b/extensions/matrix-js/openclaw.plugin.json deleted file mode 100644 index 6044d2ad998..00000000000 --- a/extensions/matrix-js/openclaw.plugin.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "id": "matrix-js", - "channels": ["matrix-js"], - "configSchema": { - "type": "object", - "additionalProperties": false, - "properties": {} - } -} diff --git a/extensions/matrix-js/package.json b/extensions/matrix-js/package.json deleted file mode 100644 index 63c7a7b54fb..00000000000 --- a/extensions/matrix-js/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@openclaw/matrix-js", - "version": "2026.2.22", - "description": "OpenClaw Matrix channel plugin", - "type": "module", - "dependencies": { - "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", - "fake-indexeddb": "^6.2.5", - "markdown-it": "14.1.0", - "matrix-js-sdk": "^40.1.0", - "music-metadata": "^11.11.2", - "zod": "^4.3.6" - }, - "devDependencies": { - "openclaw": "workspace:*" - }, - "openclaw": { - "extensions": [ - "./index.ts" - ], - "channel": { - "id": "matrix-js", - "label": "Matrix-js", - "selectionLabel": "Matrix-js (plugin)", - "docsPath": "/channels/matrix-js", - "docsLabel": "matrix-js", - "blurb": "open protocol; install the plugin to enable.", - "order": 70, - "quickstartAllowFrom": true - }, - "install": { - "npmSpec": "@openclaw/matrix-js", - "localPath": "extensions/matrix-js", - "defaultChoice": "npm" - } - } -} diff --git a/extensions/matrix-js/src/actions.ts b/extensions/matrix-js/src/actions.ts deleted file mode 100644 index 755aa7c834f..00000000000 --- a/extensions/matrix-js/src/actions.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { - createActionGate, - readNumberParam, - readStringParam, - type ChannelMessageActionAdapter, - type ChannelMessageActionContext, - type ChannelMessageActionName, - type ChannelToolSend, -} from "openclaw/plugin-sdk/matrix-js"; -import { resolveMatrixAccount } from "./matrix/accounts.js"; -import { handleMatrixAction } from "./tool-actions.js"; -import type { CoreConfig } from "./types.js"; - -const MATRIX_JS_PLUGIN_HANDLED_ACTIONS = new Set([ - "send", - "poll-vote", - "react", - "reactions", - "read", - "edit", - "delete", - "pin", - "unpin", - "list-pins", - "member-info", - "channel-info", - "permissions", -]); - -function createMatrixJsExposedActions() { - return new Set(["poll", ...MATRIX_JS_PLUGIN_HANDLED_ACTIONS]); -} - -export const matrixMessageActions: ChannelMessageActionAdapter = { - listActions: ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - if (!account.enabled || !account.configured) { - return []; - } - const gate = createActionGate((cfg as CoreConfig).channels?.["matrix-js"]?.actions); - const actions = createMatrixJsExposedActions(); - if (gate("reactions")) { - actions.add("react"); - actions.add("reactions"); - } - if (gate("messages")) { - actions.add("read"); - actions.add("edit"); - actions.add("delete"); - } - if (gate("pins")) { - actions.add("pin"); - actions.add("unpin"); - actions.add("list-pins"); - } - if (gate("memberInfo")) { - actions.add("member-info"); - } - if (gate("channelInfo")) { - actions.add("channel-info"); - } - if (account.config.encryption === true && gate("verification")) { - actions.add("permissions"); - } - return Array.from(actions); - }, - supportsAction: ({ action }) => MATRIX_JS_PLUGIN_HANDLED_ACTIONS.has(action), - extractToolSend: ({ args }): ChannelToolSend | null => { - const action = typeof args.action === "string" ? args.action.trim() : ""; - if (action !== "sendMessage") { - return null; - } - const to = typeof args.to === "string" ? args.to : undefined; - if (!to) { - return null; - } - return { to }; - }, - handleAction: async (ctx: ChannelMessageActionContext) => { - const { action, params, cfg, accountId } = ctx; - const dispatch = async (actionParams: Record) => - await handleMatrixAction( - { - ...actionParams, - ...(accountId ? { accountId } : {}), - }, - cfg as CoreConfig, - ); - const resolveRoomId = () => - readStringParam(params, "roomId") ?? - readStringParam(params, "channelId") ?? - readStringParam(params, "to", { required: true }); - - if (action === "send") { - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "message", { - required: true, - allowEmpty: true, - }); - const mediaUrl = readStringParam(params, "media", { trim: false }); - const replyTo = readStringParam(params, "replyTo"); - const threadId = readStringParam(params, "threadId"); - return await dispatch({ - action: "sendMessage", - to, - content, - mediaUrl: mediaUrl ?? undefined, - replyToId: replyTo ?? undefined, - threadId: threadId ?? undefined, - }); - } - - if (action === "poll-vote") { - return await dispatch({ - ...params, - action: "pollVote", - }); - } - - if (action === "react") { - const messageId = readStringParam(params, "messageId", { required: true }); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await dispatch({ - action: "react", - roomId: resolveRoomId(), - messageId, - emoji, - remove, - }); - } - - if (action === "reactions") { - const messageId = readStringParam(params, "messageId", { required: true }); - const limit = readNumberParam(params, "limit", { integer: true }); - return await dispatch({ - action: "reactions", - roomId: resolveRoomId(), - messageId, - limit, - }); - } - - if (action === "read") { - const limit = readNumberParam(params, "limit", { integer: true }); - return await dispatch({ - action: "readMessages", - roomId: resolveRoomId(), - limit, - before: readStringParam(params, "before"), - after: readStringParam(params, "after"), - }); - } - - if (action === "edit") { - const messageId = readStringParam(params, "messageId", { required: true }); - const content = readStringParam(params, "message", { required: true }); - return await dispatch({ - action: "editMessage", - roomId: resolveRoomId(), - messageId, - content, - }); - } - - if (action === "delete") { - const messageId = readStringParam(params, "messageId", { required: true }); - return await dispatch({ - action: "deleteMessage", - roomId: resolveRoomId(), - messageId, - }); - } - - if (action === "pin" || action === "unpin" || action === "list-pins") { - const messageId = - action === "list-pins" - ? undefined - : readStringParam(params, "messageId", { required: true }); - return await dispatch({ - action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", - roomId: resolveRoomId(), - messageId, - }); - } - - if (action === "member-info") { - const userId = readStringParam(params, "userId", { required: true }); - return await dispatch({ - action: "memberInfo", - userId, - roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"), - }); - } - - if (action === "channel-info") { - return await dispatch({ - action: "channelInfo", - roomId: resolveRoomId(), - }); - } - - if (action === "permissions") { - const operation = ( - readStringParam(params, "operation") ?? - readStringParam(params, "mode") ?? - "verification-list" - ) - .trim() - .toLowerCase(); - const operationToAction: Record = { - "encryption-status": "encryptionStatus", - "verification-status": "verificationStatus", - "verification-bootstrap": "verificationBootstrap", - "verification-recovery-key": "verificationRecoveryKey", - "verification-backup-status": "verificationBackupStatus", - "verification-backup-restore": "verificationBackupRestore", - "verification-list": "verificationList", - "verification-request": "verificationRequest", - "verification-accept": "verificationAccept", - "verification-cancel": "verificationCancel", - "verification-start": "verificationStart", - "verification-generate-qr": "verificationGenerateQr", - "verification-scan-qr": "verificationScanQr", - "verification-sas": "verificationSas", - "verification-confirm": "verificationConfirm", - "verification-mismatch": "verificationMismatch", - "verification-confirm-qr": "verificationConfirmQr", - }; - const resolvedAction = operationToAction[operation]; - if (!resolvedAction) { - throw new Error( - `Unsupported Matrix permissions operation: ${operation}. Supported values: ${Object.keys( - operationToAction, - ).join(", ")}`, - ); - } - return await dispatch({ - ...params, - action: resolvedAction, - }); - } - - throw new Error(`Action ${action} is not supported for provider matrix-js.`); - }, -}; diff --git a/extensions/matrix-js/src/channel.directory.test.ts b/extensions/matrix-js/src/channel.directory.test.ts deleted file mode 100644 index ec11e136eb7..00000000000 --- a/extensions/matrix-js/src/channel.directory.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixPlugin } from "./channel.js"; -import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; -import { setMatrixRuntime } from "./runtime.js"; -import type { CoreConfig } from "./types.js"; - -describe("matrix directory", () => { - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number): never => { - throw new Error(`exit ${code}`); - }), - }; - - beforeEach(() => { - setMatrixRuntime({ - state: { - resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(), - }, - } as PluginRuntime); - }); - - it("lists peers and groups from config", async () => { - const cfg = { - channels: { - "matrix-js": { - dm: { allowFrom: ["matrix:@alice:example.org", "bob"] }, - groupAllowFrom: ["@dana:example.org"], - groups: { - "!room1:example.org": { users: ["@carol:example.org"] }, - "#alias:example.org": { users: [] }, - }, - }, - }, - } as unknown as CoreConfig; - - expect(matrixPlugin.directory).toBeTruthy(); - expect(matrixPlugin.directory?.listPeers).toBeTruthy(); - expect(matrixPlugin.directory?.listGroups).toBeTruthy(); - - await expect( - matrixPlugin.directory!.listPeers!({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "user", id: "user:@alice:example.org" }, - { kind: "user", id: "bob", name: "incomplete id; expected @user:server" }, - { kind: "user", id: "user:@carol:example.org" }, - { kind: "user", id: "user:@dana:example.org" }, - ]), - ); - - await expect( - matrixPlugin.directory!.listGroups!({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "group", id: "room:!room1:example.org" }, - { kind: "group", id: "#alias:example.org" }, - ]), - ); - }); - - it("resolves replyToMode from account config", () => { - const cfg = { - channels: { - "matrix-js": { - replyToMode: "off", - accounts: { - Assistant: { - replyToMode: "all", - }, - }, - }, - }, - } as unknown as CoreConfig; - - expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy(); - expect( - matrixPlugin.threading?.resolveReplyToMode?.({ - cfg, - accountId: "assistant", - chatType: "direct", - }), - ).toBe("all"); - expect( - matrixPlugin.threading?.resolveReplyToMode?.({ - cfg, - accountId: "default", - chatType: "direct", - }), - ).toBe("off"); - }); - - it("only exposes real Matrix thread ids in tool context", () => { - expect( - matrixPlugin.threading?.buildToolContext?.({ - context: { - To: "room:!room:example.org", - ReplyToId: "$reply", - }, - hasRepliedRef: { value: false }, - }), - ).toEqual({ - currentChannelId: "room:!room:example.org", - currentThreadTs: undefined, - hasRepliedRef: { value: false }, - }); - - expect( - matrixPlugin.threading?.buildToolContext?.({ - context: { - To: "room:!room:example.org", - ReplyToId: "$reply", - MessageThreadId: "$thread", - }, - hasRepliedRef: { value: true }, - }), - ).toEqual({ - currentChannelId: "room:!room:example.org", - currentThreadTs: "$thread", - hasRepliedRef: { value: true }, - }); - }); - - it("exposes Matrix direct user id in dm tool context", () => { - expect( - matrixPlugin.threading?.buildToolContext?.({ - context: { - From: "matrix:@alice:example.org", - To: "room:!dm:example.org", - ChatType: "direct", - MessageThreadId: "$thread", - }, - hasRepliedRef: { value: false }, - }), - ).toEqual({ - currentChannelId: "room:!dm:example.org", - currentThreadTs: "$thread", - currentDirectUserId: "@alice:example.org", - hasRepliedRef: { value: false }, - }); - }); - - it("resolves group mention policy from account config", () => { - const cfg = { - channels: { - "matrix-js": { - groups: { - "!room:example.org": { requireMention: true }, - }, - accounts: { - Assistant: { - groups: { - "!room:example.org": { requireMention: false }, - }, - }, - }, - }, - }, - } as unknown as CoreConfig; - - expect(matrixPlugin.groups!.resolveRequireMention!({ cfg, groupId: "!room:example.org" })).toBe( - true, - ); - expect( - matrixPlugin.groups!.resolveRequireMention!({ - cfg, - accountId: "assistant", - groupId: "!room:example.org", - }), - ).toBe(false); - }); - - it("writes matrix-js non-default account credentials under channels.matrix-js.accounts", () => { - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://default.example.org", - accessToken: "default-token", - }, - }, - } as unknown as CoreConfig; - - const updated = matrixPlugin.setup!.applyAccountConfig({ - cfg, - accountId: "ops", - input: { - homeserver: "https://matrix.example.org", - userId: "@ops:example.org", - accessToken: "ops-token", - }, - }) as CoreConfig; - - expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ - accessToken: "default-token", - homeserver: "https://default.example.org", - }); - expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({ - enabled: true, - homeserver: "https://matrix.example.org", - userId: "@ops:example.org", - accessToken: "ops-token", - }); - }); - - it("writes default matrix-js account credentials under channels.matrix-js.accounts.default", () => { - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://legacy.example.org", - accessToken: "legacy-token", - }, - }, - } as unknown as CoreConfig; - - const updated = matrixPlugin.setup!.applyAccountConfig({ - cfg, - accountId: "default", - input: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "bot-token", - }, - }) as CoreConfig; - - expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ - enabled: true, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "bot-token", - }); - }); - - it("migrates legacy top-level matrix-js credentials into accounts.default", () => { - const cfg = { - channels: { - "matrix-js": { - name: "bot-default", - homeserver: "https://legacy.example.org", - userId: "@legacy:example.org", - accessToken: "legacy-token", - deviceName: "Legacy Device", - encryption: true, - groupPolicy: "allowlist", - groups: { - "!legacy-room:example.org": { allow: true }, - }, - register: false, - }, - }, - } as unknown as CoreConfig; - - const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg); - expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.encryption).toBeUndefined(); - expect((updated.channels?.["matrix-js"] as Record)?.register).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ - name: "bot-default", - homeserver: "https://legacy.example.org", - userId: "@legacy:example.org", - accessToken: "legacy-token", - deviceName: "Legacy Device", - encryption: true, - groupPolicy: "allowlist", - groups: { - "!legacy-room:example.org": { allow: true }, - }, - }); - }); - - it("merges top-level object defaults into accounts.default during migration", () => { - const cfg = { - channels: { - "matrix-js": { - dm: { - policy: "allowlist", - allowFrom: ["@legacy:example.org"], - }, - accounts: { - default: { - dm: { - policy: "pairing", - }, - }, - }, - }, - }, - } as unknown as CoreConfig; - - const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg); - expect(updated.channels?.["matrix-js"]?.dm).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accounts?.default?.dm).toMatchObject({ - policy: "pairing", - allowFrom: ["@legacy:example.org"], - }); - }); - - it("requires account-scoped env vars when --use-env is set for non-default accounts", () => { - const envKeys = [ - "MATRIX_OPS_HOMESERVER", - "MATRIX_OPS_USER_ID", - "MATRIX_OPS_ACCESS_TOKEN", - "MATRIX_OPS_PASSWORD", - ] as const; - const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])) as Record< - (typeof envKeys)[number], - string | undefined - >; - for (const key of envKeys) { - delete process.env[key]; - } - try { - const error = matrixPlugin.setup!.validateInput?.({ - cfg: {} as CoreConfig, - accountId: "ops", - input: { useEnv: true }, - }); - expect(error).toBe( - 'Set per-account env vars for "ops" (for example MATRIX_OPS_HOMESERVER + MATRIX_OPS_ACCESS_TOKEN or MATRIX_OPS_USER_ID + MATRIX_OPS_PASSWORD).', - ); - } finally { - for (const key of envKeys) { - if (previousEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = previousEnv[key]; - } - } - } - }); - - it("accepts --use-env for non-default account when scoped env vars are present", () => { - const envKeys = { - MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER, - MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, - }; - process.env.MATRIX_OPS_HOMESERVER = "https://ops.example.org"; - process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-token"; - try { - const error = matrixPlugin.setup!.validateInput?.({ - cfg: {} as CoreConfig, - accountId: "ops", - input: { useEnv: true }, - }); - expect(error).toBeNull(); - } finally { - for (const [key, value] of Object.entries(envKeys)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } - }); - - it("resolves account id from input name when explicit account id is missing", () => { - const accountId = matrixPlugin.setup!.resolveAccountId?.({ - cfg: {} as CoreConfig, - accountId: undefined, - input: { name: "Main Bot" }, - }); - expect(accountId).toBe("main-bot"); - }); - - it("resolves binding account id from agent id when omitted", () => { - const accountId = matrixPlugin.setup!.resolveBindingAccountId?.({ - cfg: {} as CoreConfig, - agentId: "Ops", - accountId: undefined, - }); - expect(accountId).toBe("ops"); - }); - - it("clears stale access token when switching an account to password auth", () => { - const cfg = { - channels: { - "matrix-js": { - accounts: { - default: { - homeserver: "https://matrix.example.org", - accessToken: "old-token", - }, - }, - }, - }, - } as unknown as CoreConfig; - - const updated = matrixPlugin.setup!.applyAccountConfig({ - cfg, - accountId: "default", - input: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "new-password", - }, - }) as CoreConfig; - - expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBe("new-password"); - expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBeUndefined(); - }); - - it("clears stale password when switching an account to token auth", () => { - const cfg = { - channels: { - "matrix-js": { - accounts: { - default: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "old-password", - }, - }, - }, - }, - } as unknown as CoreConfig; - - const updated = matrixPlugin.setup!.applyAccountConfig({ - cfg, - accountId: "default", - input: { - homeserver: "https://matrix.example.org", - accessToken: "new-token", - }, - }) as CoreConfig; - - expect(updated.channels?.["matrix-js"]?.accounts?.default?.accessToken).toBe("new-token"); - expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined(); - }); -}); diff --git a/extensions/matrix-js/src/channel.ts b/extensions/matrix-js/src/channel.ts deleted file mode 100644 index 866c81a5e10..00000000000 --- a/extensions/matrix-js/src/channel.ts +++ /dev/null @@ -1,515 +0,0 @@ -import { - applyAccountNameToChannelSection, - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatPairingApproveHint, - migrateBaseNameToDefaultAccount, - normalizeAccountId, - PAIRING_APPROVED_MESSAGE, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - setAccountEnabledInConfigSection, - type ChannelSetupInput, - type ChannelPlugin, -} from "openclaw/plugin-sdk/matrix-js"; -import { matrixMessageActions } from "./actions.js"; -import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; -import { MatrixConfigSchema } from "./config-schema.js"; -import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; -import { - resolveMatrixGroupRequireMention, - resolveMatrixGroupToolPolicy, -} from "./group-mentions.js"; -import { - listMatrixAccountIds, - resolveMatrixAccountConfig, - resolveDefaultMatrixAccountId, - resolveMatrixAccount, - type ResolvedMatrixAccount, -} from "./matrix/accounts.js"; -import { - getMatrixScopedEnvVarNames, - hasReadyMatrixEnvAuth, - resolveMatrixAuth, - resolveScopedMatrixEnvConfig, -} from "./matrix/client.js"; -import { updateMatrixAccountConfig } from "./matrix/config-update.js"; -import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; -import { probeMatrix } from "./matrix/probe.js"; -import { isSupportedMatrixAvatarSource } from "./matrix/profile.js"; -import { sendMessageMatrix } from "./matrix/send.js"; -import { matrixOnboardingAdapter } from "./onboarding.js"; -import { matrixOutbound } from "./outbound.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; -import type { CoreConfig } from "./types.js"; - -// Mutex for serializing account startup (workaround for concurrent dynamic import race condition) -let matrixStartupLock: Promise = Promise.resolve(); - -const meta = { - id: "matrix-js", - label: "Matrix-js", - selectionLabel: "Matrix-js (plugin)", - docsPath: "/channels/matrix-js", - docsLabel: "matrix-js", - blurb: "open protocol; configure a homeserver + access token.", - order: 70, - quickstartAllowFrom: true, -}; - -function normalizeMatrixMessagingTarget(raw: string): string | undefined { - let normalized = raw.trim(); - if (!normalized) { - return undefined; - } - const lowered = normalized.toLowerCase(); - if (lowered.startsWith("matrix:")) { - normalized = normalized.slice("matrix:".length).trim(); - } - const stripped = normalized.replace(/^(room|channel|user):/i, "").trim(); - return stripped || undefined; -} - -function resolveMatrixDirectUserId(params: { - from?: string; - to?: string; - chatType?: string; -}): string | undefined { - if (params.chatType !== "direct") { - return undefined; - } - const from = params.from?.trim(); - const to = params.to?.trim(); - if (!from || !to || !/^room:/i.test(to)) { - return undefined; - } - const normalized = from - .replace(/^matrix:/i, "") - .replace(/^user:/i, "") - .trim(); - return normalized.startsWith("@") ? normalized : undefined; -} - -function resolveAvatarInput(input: ChannelSetupInput): string | undefined { - const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl; - const trimmed = avatarUrl?.trim(); - return trimmed ? trimmed : undefined; -} - -export const matrixPlugin: ChannelPlugin = { - id: "matrix-js", - meta, - onboarding: matrixOnboardingAdapter, - pairing: { - idLabel: "matrixUserId", - normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), - notifyApproval: async ({ id }) => { - await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); - }, - }, - capabilities: { - chatTypes: ["direct", "group", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - }, - reload: { configPrefixes: ["channels.matrix-js"] }, - configSchema: buildChannelConfigSchema(MatrixConfigSchema), - config: { - listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), - resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), - defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "matrix-js", - accountId, - enabled, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg: cfg as CoreConfig, - sectionKey: "matrix-js", - accountId, - clearBaseFields: [ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceName", - "avatarUrl", - "initialSyncLimit", - ], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.homeserver, - }), - resolveAllowFrom: ({ cfg, accountId }) => { - const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }); - return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry)); - }, - formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom), - }, - security: { - resolveDmPolicy: ({ account }) => { - const accountId = account.accountId; - const prefix = - accountId && accountId !== "default" - ? `channels.matrix-js.accounts.${accountId}.dm` - : "channels.matrix-js.dm"; - return { - policy: account.config.dm?.policy ?? "pairing", - allowFrom: account.config.dm?.allowFrom ?? [], - policyPath: `${prefix}.policy`, - allowFromPath: `${prefix}.allowFrom`, - approveHint: formatPairingApproveHint("matrix-js"), - normalizeEntry: (raw) => normalizeMatrixUserId(raw), - }; - }, - collectWarnings: ({ account, cfg }) => { - const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig); - const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ - providerConfigPresent: (cfg as CoreConfig).channels?.["matrix-js"] !== undefined, - groupPolicy: account.config.groupPolicy, - defaultGroupPolicy, - }); - if (groupPolicy !== "open") { - return []; - } - return [ - '- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix-js.groupPolicy="allowlist" + channels.matrix-js.groups (and optionally channels.matrix-js.groupAllowFrom) to restrict rooms.', - ]; - }, - }, - groups: { - resolveRequireMention: resolveMatrixGroupRequireMention, - resolveToolPolicy: resolveMatrixGroupToolPolicy, - }, - threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", - buildToolContext: ({ context, hasRepliedRef }) => { - const currentTarget = context.To; - return { - currentChannelId: currentTarget?.trim() || undefined, - currentThreadTs: - context.MessageThreadId != null ? String(context.MessageThreadId) : undefined, - currentDirectUserId: resolveMatrixDirectUserId({ - from: context.From, - to: context.To, - chatType: context.ChatType, - }), - hasRepliedRef, - }; - }, - }, - messaging: { - normalizeTarget: normalizeMatrixMessagingTarget, - targetResolver: { - looksLikeId: (raw) => { - const trimmed = raw.trim(); - if (!trimmed) { - return false; - } - if (/^(matrix:)?[!#@]/i.test(trimmed)) { - return true; - } - return trimmed.includes(":"); - }, - hint: "", - }, - }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - - for (const entry of account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - for (const entry of account.config.groupAllowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - const groups = account.config.groups ?? account.config.rooms ?? {}; - for (const room of Object.values(groups)) { - for (const entry of room.users ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - } - - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { - const lowered = raw.toLowerCase(); - const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; - if (cleaned.startsWith("@")) { - return `user:${cleaned}`; - } - return cleaned; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => { - const raw = id.startsWith("user:") ? id.slice("user:".length) : id; - const incomplete = !raw.startsWith("@") || !raw.includes(":"); - return { - kind: "user", - id, - ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), - }; - }); - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const groups = account.config.groups ?? account.config.rooms ?? {}; - const ids = Object.keys(groups) - .map((raw) => raw.trim()) - .filter((raw) => Boolean(raw) && raw !== "*") - .map((raw) => raw.replace(/^matrix:/i, "")) - .map((raw) => { - const lowered = raw.toLowerCase(); - if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { - return raw; - } - if (raw.startsWith("!")) { - return `room:${raw}`; - } - return raw; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - return ids; - }, - listPeersLive: async ({ cfg, accountId, query, limit }) => - listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }), - listGroupsLive: async ({ cfg, accountId, query, limit }) => - listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }), - }, - resolver: { - resolveTargets: async ({ cfg, inputs, kind, runtime }) => - resolveMatrixTargets({ cfg, inputs, kind, runtime }), - }, - actions: matrixMessageActions, - setup: { - resolveAccountId: ({ accountId, input }) => - normalizeAccountId(accountId?.trim() || input?.name?.trim()), - resolveBindingAccountId: ({ agentId, accountId }) => - normalizeAccountId(accountId?.trim() || agentId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix-js", - accountId, - name, - alwaysUseAccounts: true, - }), - validateInput: ({ accountId, input }) => { - const avatarUrl = resolveAvatarInput(input); - if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) { - return "Matrix avatar URL must be an mxc:// URI or an http(s) URL"; - } - if (input.useEnv) { - const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); - const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); - if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) { - const keys = getMatrixScopedEnvVarNames(accountId); - return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`; - } - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = input.password?.trim(); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig); - const namedConfig = applyAccountNameToChannelSection({ - cfg: migratedConfig, - channelKey: "matrix-js", - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "matrix-js", - }); - if (input.useEnv) { - return setAccountEnabledInConfigSection({ - cfg: next as CoreConfig, - sectionKey: "matrix-js", - accountId, - enabled: true, - }) as CoreConfig; - } - const accessToken = input.accessToken?.trim(); - const password = input.password?.trim(); - const userId = input.userId?.trim(); - return updateMatrixAccountConfig(next as CoreConfig, accountId, { - homeserver: input.homeserver?.trim(), - userId: password && !userId ? null : userId, - accessToken: accessToken || (password ? null : undefined), - password: password || (accessToken ? null : undefined), - deviceName: input.deviceName?.trim(), - avatarUrl: resolveAvatarInput(input), - initialSyncLimit: input.initialSyncLimit, - }); - }, - }, - outbound: matrixOutbound, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, - }, - collectStatusIssues: (accounts) => - accounts.flatMap((account) => { - const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; - if (!lastError) { - return []; - } - return [ - { - channel: "matrix-js", - accountId: account.accountId, - kind: "runtime", - message: `Channel error: ${lastError}`, - }, - ]; - }), - buildChannelSummary: ({ snapshot }) => ({ - configured: snapshot.configured ?? false, - baseUrl: snapshot.baseUrl ?? null, - running: snapshot.running ?? false, - lastStartAt: snapshot.lastStartAt ?? null, - lastStopAt: snapshot.lastStopAt ?? null, - lastError: snapshot.lastError ?? null, - probe: snapshot.probe, - lastProbeAt: snapshot.lastProbeAt ?? null, - }), - probeAccount: async ({ account, timeoutMs, cfg }) => { - try { - const auth = await resolveMatrixAuth({ - cfg: cfg as CoreConfig, - accountId: account.accountId, - }); - return await probeMatrix({ - homeserver: auth.homeserver, - accessToken: auth.accessToken, - userId: auth.userId, - timeoutMs, - }); - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - elapsedMs: 0, - }; - } - }, - buildAccountSnapshot: ({ account, runtime, probe }) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.homeserver, - running: runtime?.running ?? false, - lastStartAt: runtime?.lastStartAt ?? null, - lastStopAt: runtime?.lastStopAt ?? null, - lastError: runtime?.lastError ?? null, - probe, - lastProbeAt: runtime?.lastProbeAt ?? null, - lastInboundAt: runtime?.lastInboundAt ?? null, - lastOutboundAt: runtime?.lastOutboundAt ?? null, - }), - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - ctx.setStatus({ - accountId: account.accountId, - baseUrl: account.homeserver, - }); - ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`); - - // Serialize startup: wait for any previous startup to complete import phase. - // This works around a race condition with concurrent dynamic imports. - // - // INVARIANT: The import() below cannot hang because: - // 1. It only loads local ESM modules with no circular awaits - // 2. Module initialization is synchronous (no top-level await in ./matrix/index.js) - // 3. The lock only serializes the import phase, not the provider startup - const previousLock = matrixStartupLock; - let releaseLock: () => void = () => {}; - matrixStartupLock = new Promise((resolve) => { - releaseLock = resolve; - }); - await previousLock; - - // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. - // Wrap in try/finally to ensure lock is released even if import fails. - let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider; - try { - const module = await import("./matrix/index.js"); - monitorMatrixProvider = module.monitorMatrixProvider; - } finally { - // Release lock after import completes or fails - releaseLock(); - } - - return monitorMatrixProvider({ - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - mediaMaxMb: account.config.mediaMaxMb, - initialSyncLimit: account.config.initialSyncLimit, - replyToMode: account.config.replyToMode, - accountId: account.accountId, - }); - }, - }, -}; diff --git a/extensions/matrix-js/src/config-migration.ts b/extensions/matrix-js/src/config-migration.ts deleted file mode 100644 index 9ff1f1001f5..00000000000 --- a/extensions/matrix-js/src/config-migration.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/matrix-js"; -import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js"; - -type LegacyAccountField = - | "name" - | "homeserver" - | "userId" - | "accessToken" - | "password" - | "deviceId" - | "deviceName" - | "initialSyncLimit" - | "encryption" - | "allowlistOnly" - | "groupPolicy" - | "groupAllowFrom" - | "replyToMode" - | "threadReplies" - | "textChunkLimit" - | "chunkMode" - | "responsePrefix" - | "threadBindings" - | "startupVerification" - | "startupVerificationCooldownHours" - | "mediaMaxMb" - | "autoJoin" - | "autoJoinAllowlist" - | "dm" - | "groups" - | "rooms" - | "actions"; - -const LEGACY_ACCOUNT_FIELDS: ReadonlyArray = [ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceId", - "deviceName", - "initialSyncLimit", - "encryption", - "allowlistOnly", - "groupPolicy", - "groupAllowFrom", - "replyToMode", - "threadReplies", - "textChunkLimit", - "chunkMode", - "responsePrefix", - "threadBindings", - "startupVerification", - "startupVerificationCooldownHours", - "mediaMaxMb", - "autoJoin", - "autoJoinAllowlist", - "dm", - "groups", - "rooms", - "actions", -]; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function mergeLegacyFieldIntoDefault( - current: MatrixAccountConfig[LegacyAccountField] | undefined, - legacy: MatrixAccountConfig[LegacyAccountField], -): MatrixAccountConfig[LegacyAccountField] { - if (current === undefined) { - return legacy; - } - if (isRecord(current) && isRecord(legacy)) { - return { - ...legacy, - ...current, - } as MatrixAccountConfig[LegacyAccountField]; - } - return current; -} - -function clearLegacyOnlyFields(nextMatrix: MatrixConfig): void { - // Legacy matrix-bot-sdk onboarding toggle; not used by matrix-js config. - delete (nextMatrix as Record).register; -} - -export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig { - const matrix = cfg.channels?.["matrix-js"]; - if (!matrix) { - return cfg; - } - - const defaultAccount = { - ...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}), - } as MatrixAccountConfig; - let changed = false; - - for (const field of LEGACY_ACCOUNT_FIELDS) { - const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined; - if (legacyValue === undefined) { - continue; - } - ( - defaultAccount as Record< - LegacyAccountField, - MatrixAccountConfig[LegacyAccountField] | undefined - > - )[field] = mergeLegacyFieldIntoDefault(defaultAccount[field], legacyValue); - changed = true; - } - - const registerPresent = (matrix as Record).register !== undefined; - if (registerPresent) { - changed = true; - } - - if (!changed) { - return cfg; - } - - const nextMatrix = { ...matrix } as MatrixConfig; - for (const field of LEGACY_ACCOUNT_FIELDS) { - delete nextMatrix[field]; - } - clearLegacyOnlyFields(nextMatrix); - nextMatrix.accounts = { - ...matrix.accounts, - [DEFAULT_ACCOUNT_ID]: defaultAccount, - }; - - return { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": nextMatrix, - }, - }; -} diff --git a/extensions/matrix-js/src/config-schema.ts b/extensions/matrix-js/src/config-schema.ts deleted file mode 100644 index b4823848368..00000000000 --- a/extensions/matrix-js/src/config-schema.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix-js"; -import { z } from "zod"; - -const allowFromEntry = z.union([z.string(), z.number()]); - -const matrixActionSchema = z - .object({ - reactions: z.boolean().optional(), - messages: z.boolean().optional(), - pins: z.boolean().optional(), - memberInfo: z.boolean().optional(), - channelInfo: z.boolean().optional(), - verification: z.boolean().optional(), - }) - .optional(); - -const matrixThreadBindingsSchema = z - .object({ - enabled: z.boolean().optional(), - idleHours: z.number().nonnegative().optional(), - maxAgeHours: z.number().nonnegative().optional(), - spawnSubagentSessions: z.boolean().optional(), - spawnAcpSessions: z.boolean().optional(), - }) - .optional(); - -const matrixDmSchema = z - .object({ - enabled: z.boolean().optional(), - policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), - allowFrom: z.array(allowFromEntry).optional(), - }) - .optional(); - -const matrixRoomSchema = z - .object({ - enabled: z.boolean().optional(), - allow: z.boolean().optional(), - requireMention: z.boolean().optional(), - tools: ToolPolicySchema, - autoReply: z.boolean().optional(), - users: z.array(allowFromEntry).optional(), - skills: z.array(z.string()).optional(), - systemPrompt: z.string().optional(), - }) - .optional(); - -export const MatrixConfigSchema = z.object({ - name: z.string().optional(), - enabled: z.boolean().optional(), - markdown: MarkdownConfigSchema, - homeserver: z.string().optional(), - userId: z.string().optional(), - accessToken: z.string().optional(), - password: z.string().optional(), - deviceId: z.string().optional(), - deviceName: z.string().optional(), - avatarUrl: z.string().optional(), - initialSyncLimit: z.number().optional(), - encryption: z.boolean().optional(), - allowlistOnly: z.boolean().optional(), - groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), - replyToMode: z.enum(["off", "first", "all"]).optional(), - threadReplies: z.enum(["off", "inbound", "always"]).optional(), - textChunkLimit: z.number().optional(), - chunkMode: z.enum(["length", "newline"]).optional(), - responsePrefix: z.string().optional(), - ackReaction: z.string().optional(), - ackReactionScope: z - .enum(["group-mentions", "group-all", "direct", "all", "none", "off"]) - .optional(), - reactionNotifications: z.enum(["off", "own"]).optional(), - threadBindings: matrixThreadBindingsSchema, - startupVerification: z.enum(["off", "if-unverified"]).optional(), - startupVerificationCooldownHours: z.number().optional(), - mediaMaxMb: z.number().optional(), - autoJoin: z.enum(["always", "allowlist", "off"]).optional(), - autoJoinAllowlist: z.array(allowFromEntry).optional(), - groupAllowFrom: z.array(allowFromEntry).optional(), - dm: matrixDmSchema, - groups: z.object({}).catchall(matrixRoomSchema).optional(), - rooms: z.object({}).catchall(matrixRoomSchema).optional(), - actions: matrixActionSchema, -}); diff --git a/extensions/matrix-js/src/directory-live.test.ts b/extensions/matrix-js/src/directory-live.test.ts deleted file mode 100644 index 405624ccaa9..00000000000 --- a/extensions/matrix-js/src/directory-live.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; -import { resolveMatrixAuth } from "./matrix/client.js"; - -vi.mock("./matrix/client.js", () => ({ - resolveMatrixAuth: vi.fn(), -})); - -describe("matrix directory live", () => { - const cfg = { channels: { "matrix-js": {} } }; - - beforeEach(() => { - vi.mocked(resolveMatrixAuth).mockReset(); - vi.mocked(resolveMatrixAuth).mockResolvedValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "test-token", - }); - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ results: [] }), - text: async () => "", - }), - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("passes accountId to peer directory auth resolution", async () => { - await listMatrixDirectoryPeersLive({ - cfg, - accountId: "assistant", - query: "alice", - limit: 10, - }); - - expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" }); - }); - - it("passes accountId to group directory auth resolution", async () => { - await listMatrixDirectoryGroupsLive({ - cfg, - accountId: "assistant", - query: "!room:example.org", - limit: 10, - }); - - expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" }); - }); - - it("returns no peer results for empty query without resolving auth", async () => { - const result = await listMatrixDirectoryPeersLive({ - cfg, - query: " ", - }); - - expect(result).toEqual([]); - expect(resolveMatrixAuth).not.toHaveBeenCalled(); - }); - - it("returns no group results for empty query without resolving auth", async () => { - const result = await listMatrixDirectoryGroupsLive({ - cfg, - query: "", - }); - - expect(result).toEqual([]); - expect(resolveMatrixAuth).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/matrix-js/src/directory-live.ts b/extensions/matrix-js/src/directory-live.ts deleted file mode 100644 index 0fefdc45f2c..00000000000 --- a/extensions/matrix-js/src/directory-live.ts +++ /dev/null @@ -1,208 +0,0 @@ -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js"; -import { resolveMatrixAuth } from "./matrix/client.js"; - -type MatrixUserResult = { - user_id?: string; - display_name?: string; -}; - -type MatrixUserDirectoryResponse = { - results?: MatrixUserResult[]; -}; - -type MatrixJoinedRoomsResponse = { - joined_rooms?: string[]; -}; - -type MatrixRoomNameState = { - name?: string; -}; - -type MatrixAliasLookup = { - room_id?: string; -}; - -type MatrixDirectoryLiveParams = { - cfg: unknown; - accountId?: string | null; - query?: string | null; - limit?: number | null; -}; - -type MatrixResolvedAuth = Awaited>; - -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; -} - -function normalizeQuery(value?: string | null): string { - return value?.trim().toLowerCase() ?? ""; -} - -function resolveMatrixDirectoryLimit(limit?: number | null): number { - return typeof limit === "number" && limit > 0 ? limit : 20; -} - -async function resolveMatrixDirectoryContext( - params: MatrixDirectoryLiveParams, -): Promise<{ query: string; auth: MatrixResolvedAuth } | null> { - const query = normalizeQuery(params.query); - if (!query) { - return null; - } - const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId }); - return { query, auth }; -} - -function createGroupDirectoryEntry(params: { - id: string; - name: string; - handle?: string; -}): ChannelDirectoryEntry { - return { - kind: "group", - id: params.id, - name: params.name, - handle: params.handle, - } satisfies ChannelDirectoryEntry; -} - -export async function listMatrixDirectoryPeersLive( - params: MatrixDirectoryLiveParams, -): Promise { - const context = await resolveMatrixDirectoryContext(params); - if (!context) { - return []; - } - const { query, auth } = context; - const res = await fetchMatrixJson({ - homeserver: auth.homeserver, - accessToken: auth.accessToken, - path: "/_matrix/client/v3/user_directory/search", - method: "POST", - body: { - search_term: query, - limit: resolveMatrixDirectoryLimit(params.limit), - }, - }); - const results = res.results ?? []; - return results - .map((entry) => { - const userId = entry.user_id?.trim(); - if (!userId) { - return null; - } - return { - kind: "user", - id: userId, - name: entry.display_name?.trim() || undefined, - handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined, - raw: entry, - } satisfies ChannelDirectoryEntry; - }) - .filter(Boolean) as ChannelDirectoryEntry[]; -} - -async function resolveMatrixRoomAlias( - homeserver: string, - accessToken: string, - alias: string, -): Promise { - try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, - }); - return res.room_id?.trim() || null; - } catch { - return null; - } -} - -async function fetchMatrixRoomName( - homeserver: string, - accessToken: string, - roomId: string, -): Promise { - try { - const res = await fetchMatrixJson({ - homeserver, - accessToken, - path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, - }); - return res.name?.trim() || null; - } catch { - return null; - } -} - -export async function listMatrixDirectoryGroupsLive( - params: MatrixDirectoryLiveParams, -): Promise { - const context = await resolveMatrixDirectoryContext(params); - if (!context) { - return []; - } - const { query, auth } = context; - const limit = resolveMatrixDirectoryLimit(params.limit); - - if (query.startsWith("#")) { - const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query); - if (!roomId) { - return []; - } - return [createGroupDirectoryEntry({ id: roomId, name: query, handle: query })]; - } - - if (query.startsWith("!")) { - return [createGroupDirectoryEntry({ id: query, name: query })]; - } - - const joined = await fetchMatrixJson({ - homeserver: auth.homeserver, - accessToken: auth.accessToken, - path: "/_matrix/client/v3/joined_rooms", - }); - const rooms = joined.joined_rooms ?? []; - const results: ChannelDirectoryEntry[] = []; - - for (const roomId of rooms) { - const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId); - if (!name) { - continue; - } - if (!name.toLowerCase().includes(query)) { - continue; - } - results.push({ - kind: "group", - id: roomId, - name, - handle: `#${name}`, - }); - if (results.length >= limit) { - break; - } - } - - return results; -} diff --git a/extensions/matrix-js/src/group-mentions.ts b/extensions/matrix-js/src/group-mentions.ts deleted file mode 100644 index f6d3540f5d0..00000000000 --- a/extensions/matrix-js/src/group-mentions.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk/matrix-js"; -import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; -import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; -import type { CoreConfig } from "./types.js"; - -function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string { - return value.toLowerCase().startsWith(prefix.toLowerCase()) - ? value.slice(prefix.length).trim() - : value; -} - -function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) { - const rawGroupId = params.groupId?.trim() ?? ""; - let roomId = rawGroupId; - roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:"); - roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:"); - roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:"); - - const groupChannel = params.groupChannel?.trim() ?? ""; - const aliases = groupChannel ? [groupChannel] : []; - const cfg = params.cfg as CoreConfig; - const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); - return resolveMatrixRoomConfig({ - rooms: matrixConfig.groups ?? matrixConfig.rooms, - roomId, - aliases, - name: groupChannel || undefined, - }).config; -} - -export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { - const resolved = resolveMatrixRoomConfigForGroup(params); - if (resolved) { - if (resolved.autoReply === true) { - return false; - } - if (resolved.autoReply === false) { - return true; - } - if (typeof resolved.requireMention === "boolean") { - return resolved.requireMention; - } - } - return true; -} - -export function resolveMatrixGroupToolPolicy( - params: ChannelGroupContext, -): GroupToolPolicyConfig | undefined { - const resolved = resolveMatrixRoomConfigForGroup(params); - return resolved?.tools; -} diff --git a/extensions/matrix-js/src/matrix/accounts.test.ts b/extensions/matrix-js/src/matrix/accounts.test.ts deleted file mode 100644 index f61b123fc32..00000000000 --- a/extensions/matrix-js/src/matrix/accounts.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { CoreConfig } from "../types.js"; -import { resolveMatrixAccount } from "./accounts.js"; - -vi.mock("./credentials.js", () => ({ - loadMatrixCredentials: () => null, - credentialsMatchConfig: () => false, -})); - -const envKeys = [ - "MATRIX_HOMESERVER", - "MATRIX_USER_ID", - "MATRIX_ACCESS_TOKEN", - "MATRIX_PASSWORD", - "MATRIX_DEVICE_NAME", -]; - -describe("resolveMatrixAccount", () => { - let prevEnv: Record = {}; - - beforeEach(() => { - prevEnv = {}; - for (const key of envKeys) { - prevEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of envKeys) { - const value = prevEnv[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - }); - - it("treats access-token-only config as configured", () => { - const cfg: CoreConfig = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - accessToken: "tok-access", - }, - }, - }; - - const account = resolveMatrixAccount({ cfg }); - expect(account.configured).toBe(true); - }); - - it("requires userId + password when no access token is set", () => { - const cfg: CoreConfig = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - }, - }, - }; - - const account = resolveMatrixAccount({ cfg }); - expect(account.configured).toBe(false); - }); - - it("marks password auth as configured when userId is present", () => { - const cfg: CoreConfig = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - }, - }, - }; - - const account = resolveMatrixAccount({ cfg }); - expect(account.configured).toBe(true); - }); -}); diff --git a/extensions/matrix-js/src/matrix/accounts.ts b/extensions/matrix-js/src/matrix/accounts.ts deleted file mode 100644 index a25dde78965..00000000000 --- a/extensions/matrix-js/src/matrix/accounts.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { CoreConfig, MatrixConfig } from "../types.js"; -import { - findMatrixAccountConfig, - resolveMatrixAccountsMap, - resolveMatrixBaseConfig, -} from "./account-config.js"; -import { resolveMatrixConfigForAccount } from "./client.js"; -import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; - -/** Merge account config with top-level defaults, preserving nested objects. */ -function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig { - const merged = { ...base, ...account }; - // Deep-merge known nested objects so partial overrides inherit base fields - for (const key of ["dm", "actions"] as const) { - const b = base[key]; - const o = account[key]; - if (typeof b === "object" && b != null && typeof o === "object" && o != null) { - (merged as Record)[key] = { ...b, ...o }; - } - } - // Don't propagate the accounts map into the merged per-account config - delete (merged as Record).accounts; - return merged; -} - -export type ResolvedMatrixAccount = { - accountId: string; - enabled: boolean; - name?: string; - configured: boolean; - homeserver?: string; - userId?: string; - config: MatrixConfig; -}; - -function listConfiguredAccountIds(cfg: CoreConfig): string[] { - const accounts = resolveMatrixAccountsMap(cfg); - if (Object.keys(accounts).length === 0) { - return []; - } - // Normalize and de-duplicate keys so listing and resolution use the same semantics - return [ - ...new Set( - Object.keys(accounts) - .filter(Boolean) - .map((id) => normalizeAccountId(id)), - ), - ]; -} - -export function listMatrixAccountIds(cfg: CoreConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) { - // Fall back to default if no accounts configured (legacy top-level config) - return [DEFAULT_ACCOUNT_ID]; - } - return ids.toSorted((a, b) => a.localeCompare(b)); -} - -export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { - const ids = listMatrixAccountIds(cfg); - if (ids.includes(DEFAULT_ACCOUNT_ID)) { - return DEFAULT_ACCOUNT_ID; - } - return ids[0] ?? DEFAULT_ACCOUNT_ID; -} - -function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined { - return findMatrixAccountConfig(cfg, accountId); -} - -export function resolveMatrixAccount(params: { - cfg: CoreConfig; - accountId?: string | null; -}): ResolvedMatrixAccount { - const accountId = normalizeAccountId(params.accountId); - const matrixBase = resolveMatrixBaseConfig(params.cfg); - const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId }); - const enabled = base.enabled !== false && matrixBase.enabled !== false; - - const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env); - const hasHomeserver = Boolean(resolved.homeserver); - const hasUserId = Boolean(resolved.userId); - const hasAccessToken = Boolean(resolved.accessToken); - const hasPassword = Boolean(resolved.password); - const hasPasswordAuth = hasUserId && hasPassword; - const stored = loadMatrixCredentials(process.env, accountId); - const hasStored = - stored && resolved.homeserver - ? credentialsMatchConfig(stored, { - homeserver: resolved.homeserver, - userId: resolved.userId || "", - }) - : false; - const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored)); - return { - accountId, - enabled, - name: base.name?.trim() || undefined, - configured, - homeserver: resolved.homeserver || undefined, - userId: resolved.userId || undefined, - config: base, - }; -} - -export function resolveMatrixAccountConfig(params: { - cfg: CoreConfig; - accountId?: string | null; -}): MatrixConfig { - const accountId = normalizeAccountId(params.accountId); - const matrixBase = resolveMatrixBaseConfig(params.cfg); - const accountConfig = resolveAccountConfig(params.cfg, accountId); - if (!accountConfig) { - return matrixBase; - } - // Merge account-specific config with top-level defaults so settings like - // groupPolicy and blockStreaming inherit when not overridden. - return mergeAccountConfig(matrixBase, accountConfig); -} - -export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] { - return listMatrixAccountIds(cfg) - .map((accountId) => resolveMatrixAccount({ cfg, accountId })) - .filter((account) => account.enabled); -} diff --git a/extensions/matrix-js/src/matrix/actions.ts b/extensions/matrix-js/src/matrix/actions.ts deleted file mode 100644 index b6661351864..00000000000 --- a/extensions/matrix-js/src/matrix/actions.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type { - MatrixActionClientOpts, - MatrixMessageSummary, - MatrixReactionSummary, -} from "./actions/types.js"; -export { - sendMatrixMessage, - editMatrixMessage, - deleteMatrixMessage, - readMatrixMessages, -} from "./actions/messages.js"; -export { voteMatrixPoll } from "./actions/polls.js"; -export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; -export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; -export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; -export { updateMatrixOwnProfile } from "./actions/profile.js"; -export { - bootstrapMatrixVerification, - acceptMatrixVerification, - cancelMatrixVerification, - confirmMatrixVerificationReciprocateQr, - confirmMatrixVerificationSas, - generateMatrixVerificationQr, - getMatrixEncryptionStatus, - getMatrixRoomKeyBackupStatus, - getMatrixVerificationStatus, - getMatrixVerificationSas, - listMatrixVerifications, - mismatchMatrixVerificationSas, - requestMatrixVerification, - restoreMatrixRoomKeyBackup, - scanMatrixVerificationQr, - startMatrixVerification, - verifyMatrixRecoveryKey, -} from "./actions/verification.js"; -export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix-js/src/matrix/actions/client.ts b/extensions/matrix-js/src/matrix/actions/client.ts deleted file mode 100644 index a3981be0520..00000000000 --- a/extensions/matrix-js/src/matrix/actions/client.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig } from "../../types.js"; -import { getActiveMatrixClient } from "../active-client.js"; -import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; -import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; - -export function ensureNodeRuntime() { - if (isBunRuntime()) { - throw new Error("Matrix support requires Node (bun runtime not supported)"); - } -} - -export async function resolveActionClient( - opts: MatrixActionClientOpts = {}, -): Promise { - ensureNodeRuntime(); - if (opts.client) { - return { client: opts.client, stopOnDone: false }; - } - const active = getActiveMatrixClient(opts.accountId); - if (active) { - return { client: active, stopOnDone: false }; - } - const auth = await resolveMatrixAuth({ - cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, - accountId: opts.accountId, - }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - password: auth.password, - deviceId: auth.deviceId, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, - autoBootstrapCrypto: false, - }); - await client.prepareForOneOff(); - return { client, stopOnDone: true }; -} - -export type MatrixActionClientStopMode = "stop" | "persist"; - -export async function stopActionClient( - resolved: MatrixActionClient, - mode: MatrixActionClientStopMode = "stop", -): Promise { - if (!resolved.stopOnDone) { - return; - } - if (mode === "persist") { - await resolved.client.stopAndPersist(); - return; - } - resolved.client.stop(); -} - -export async function withResolvedActionClient( - opts: MatrixActionClientOpts, - run: (client: MatrixActionClient["client"]) => Promise, - mode: MatrixActionClientStopMode = "stop", -): Promise { - const resolved = await resolveActionClient(opts); - try { - return await run(resolved.client); - } finally { - await stopActionClient(resolved, mode); - } -} diff --git a/extensions/matrix-js/src/matrix/actions/limits.test.ts b/extensions/matrix-js/src/matrix/actions/limits.test.ts deleted file mode 100644 index d6c85ab7fae..00000000000 --- a/extensions/matrix-js/src/matrix/actions/limits.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveMatrixActionLimit } from "./limits.js"; - -describe("resolveMatrixActionLimit", () => { - it("uses fallback for non-finite values", () => { - expect(resolveMatrixActionLimit(undefined, 20)).toBe(20); - expect(resolveMatrixActionLimit(Number.NaN, 20)).toBe(20); - }); - - it("normalizes finite numbers to positive integers", () => { - expect(resolveMatrixActionLimit(7.9, 20)).toBe(7); - expect(resolveMatrixActionLimit(0, 20)).toBe(1); - expect(resolveMatrixActionLimit(-3, 20)).toBe(1); - }); -}); diff --git a/extensions/matrix-js/src/matrix/actions/limits.ts b/extensions/matrix-js/src/matrix/actions/limits.ts deleted file mode 100644 index f18d9e2c059..00000000000 --- a/extensions/matrix-js/src/matrix/actions/limits.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function resolveMatrixActionLimit(raw: unknown, fallback: number): number { - if (typeof raw !== "number" || !Number.isFinite(raw)) { - return fallback; - } - return Math.max(1, Math.floor(raw)); -} diff --git a/extensions/matrix-js/src/matrix/actions/messages.ts b/extensions/matrix-js/src/matrix/actions/messages.ts deleted file mode 100644 index 5b0d4516ed7..00000000000 --- a/extensions/matrix-js/src/matrix/actions/messages.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; -import { resolveMatrixActionLimit } from "./limits.js"; -import { summarizeMatrixRawEvent } from "./summary.js"; -import { - EventType, - MsgType, - RelationType, - type MatrixActionClientOpts, - type MatrixMessageSummary, - type MatrixRawEvent, - type RoomMessageEventContent, -} from "./types.js"; - -export async function sendMatrixMessage( - to: string, - content: string, - opts: MatrixActionClientOpts & { - mediaUrl?: string; - replyToId?: string; - threadId?: string; - } = {}, -) { - return await sendMessageMatrix(to, content, { - mediaUrl: opts.mediaUrl, - replyToId: opts.replyToId, - threadId: opts.threadId, - accountId: opts.accountId, - client: opts.client, - timeoutMs: opts.timeoutMs, - }); -} - -export async function editMatrixMessage( - roomId: string, - messageId: string, - content: string, - opts: MatrixActionClientOpts = {}, -) { - const trimmed = content.trim(); - if (!trimmed) { - throw new Error("Matrix edit requires content"); - } - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const newContent = { - msgtype: MsgType.Text, - body: trimmed, - } satisfies RoomMessageEventContent; - const payload: RoomMessageEventContent = { - msgtype: MsgType.Text, - body: `* ${trimmed}`, - "m.new_content": newContent, - "m.relates_to": { - rel_type: RelationType.Replace, - event_id: messageId, - }, - }; - const eventId = await client.sendMessage(resolvedRoom, payload); - return { eventId: eventId ?? null }; - }); -} - -export async function deleteMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { reason?: string } = {}, -) { - await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - await client.redactEvent(resolvedRoom, messageId, opts.reason); - }); -} - -export async function readMatrixMessages( - roomId: string, - opts: MatrixActionClientOpts & { - limit?: number; - before?: string; - after?: string; - } = {}, -): Promise<{ - messages: MatrixMessageSummary[]; - nextBatch?: string | null; - prevBatch?: string | null; -}> { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const limit = resolveMatrixActionLimit(opts.limit, 20); - const token = opts.before?.trim() || opts.after?.trim() || undefined; - const dir = opts.after ? "f" : "b"; - // Room history is queried via the low-level endpoint for compatibility. - const res = (await client.doRequest( - "GET", - `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, - { - dir, - limit, - from: token, - }, - )) as { chunk: MatrixRawEvent[]; start?: string; end?: string }; - const messages = res.chunk - .filter((event) => event.type === EventType.RoomMessage) - .filter((event) => !event.unsigned?.redacted_because) - .map(summarizeMatrixRawEvent); - return { - messages, - nextBatch: res.end ?? null, - prevBatch: res.start ?? null, - }; - }); -} diff --git a/extensions/matrix-js/src/matrix/actions/pins.test.ts b/extensions/matrix-js/src/matrix/actions/pins.test.ts deleted file mode 100644 index 5b621de5d63..00000000000 --- a/extensions/matrix-js/src/matrix/actions/pins.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "../sdk.js"; -import { listMatrixPins, pinMatrixMessage, unpinMatrixMessage } from "./pins.js"; - -function createPinsClient(seedPinned: string[], knownBodies: Record = {}) { - let pinned = [...seedPinned]; - const getRoomStateEvent = vi.fn(async () => ({ pinned: [...pinned] })); - const sendStateEvent = vi.fn( - async (_roomId: string, _type: string, _key: string, payload: any) => { - pinned = [...payload.pinned]; - }, - ); - const getEvent = vi.fn(async (_roomId: string, eventId: string) => { - const body = knownBodies[eventId]; - if (!body) { - throw new Error("missing"); - } - return { - event_id: eventId, - sender: "@alice:example.org", - type: "m.room.message", - origin_server_ts: 123, - content: { msgtype: "m.text", body }, - }; - }); - - return { - client: { - getRoomStateEvent, - sendStateEvent, - getEvent, - stop: vi.fn(), - } as unknown as MatrixClient, - getPinned: () => pinned, - sendStateEvent, - }; -} - -describe("matrix pins actions", () => { - it("pins a message once even when asked twice", async () => { - const { client, getPinned, sendStateEvent } = createPinsClient(["$a"]); - - const first = await pinMatrixMessage("!room:example.org", "$b", { client }); - const second = await pinMatrixMessage("!room:example.org", "$b", { client }); - - expect(first.pinned).toEqual(["$a", "$b"]); - expect(second.pinned).toEqual(["$a", "$b"]); - expect(getPinned()).toEqual(["$a", "$b"]); - expect(sendStateEvent).toHaveBeenCalledTimes(2); - }); - - it("unpinds only the selected message id", async () => { - const { client, getPinned } = createPinsClient(["$a", "$b", "$c"]); - - const result = await unpinMatrixMessage("!room:example.org", "$b", { client }); - - expect(result.pinned).toEqual(["$a", "$c"]); - expect(getPinned()).toEqual(["$a", "$c"]); - }); - - it("lists pinned ids and summarizes only resolvable events", async () => { - const { client } = createPinsClient(["$a", "$missing"], { $a: "hello" }); - - const result = await listMatrixPins("!room:example.org", { client }); - - expect(result.pinned).toEqual(["$a", "$missing"]); - expect(result.events).toEqual([ - expect.objectContaining({ - eventId: "$a", - body: "hello", - }), - ]); - }); -}); diff --git a/extensions/matrix-js/src/matrix/actions/pins.ts b/extensions/matrix-js/src/matrix/actions/pins.ts deleted file mode 100644 index ca5ca4a8524..00000000000 --- a/extensions/matrix-js/src/matrix/actions/pins.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; -import { fetchEventSummary, readPinnedEvents } from "./summary.js"; -import { - EventType, - type MatrixActionClientOpts, - type MatrixActionClient, - type MatrixMessageSummary, - type RoomPinnedEventsEventContent, -} from "./types.js"; - -type ActionClient = MatrixActionClient["client"]; - -async function withResolvedPinRoom( - roomId: string, - opts: MatrixActionClientOpts, - run: (client: ActionClient, resolvedRoom: string) => Promise, -): Promise { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - return await run(client, resolvedRoom); - }); -} - -async function updateMatrixPins( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts, - update: (current: string[]) => string[], -): Promise<{ pinned: string[] }> { - return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { - const current = await readPinnedEvents(client, resolvedRoom); - const next = update(current); - const payload: RoomPinnedEventsEventContent = { pinned: next }; - await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); - return { pinned: next }; - }); -} - -export async function pinMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[] }> { - return await updateMatrixPins(roomId, messageId, opts, (current) => - current.includes(messageId) ? current : [...current, messageId], - ); -} - -export async function unpinMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[] }> { - return await updateMatrixPins(roomId, messageId, opts, (current) => - current.filter((id) => id !== messageId), - ); -} - -export async function listMatrixPins( - roomId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { - return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { - const pinned = await readPinnedEvents(client, resolvedRoom); - const events = ( - await Promise.all( - pinned.map(async (eventId) => { - try { - return await fetchEventSummary(client, resolvedRoom, eventId); - } catch { - return null; - } - }), - ) - ).filter((event): event is MatrixMessageSummary => Boolean(event)); - return { pinned, events }; - }); -} diff --git a/extensions/matrix-js/src/matrix/actions/reactions.test.ts b/extensions/matrix-js/src/matrix/actions/reactions.test.ts deleted file mode 100644 index 2aa1eb9a471..00000000000 --- a/extensions/matrix-js/src/matrix/actions/reactions.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "../sdk.js"; -import { listMatrixReactions, removeMatrixReactions } from "./reactions.js"; - -function createReactionsClient(params: { - chunk: Array<{ - event_id?: string; - sender?: string; - key?: string; - }>; - userId?: string | null; -}) { - const doRequest = vi.fn(async (_method: string, _path: string, _query: any) => ({ - chunk: params.chunk.map((item) => ({ - event_id: item.event_id ?? "", - sender: item.sender ?? "", - content: item.key - ? { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$target", - key: item.key, - }, - } - : {}, - })), - })); - const getUserId = vi.fn(async () => params.userId ?? null); - const redactEvent = vi.fn(async () => undefined); - - return { - client: { - doRequest, - getUserId, - redactEvent, - stop: vi.fn(), - } as unknown as MatrixClient, - doRequest, - redactEvent, - }; -} - -describe("matrix reaction actions", () => { - it("aggregates reactions by key and unique sender", async () => { - const { client, doRequest } = createReactionsClient({ - chunk: [ - { event_id: "$1", sender: "@alice:example.org", key: "👍" }, - { event_id: "$2", sender: "@bob:example.org", key: "👍" }, - { event_id: "$3", sender: "@alice:example.org", key: "👎" }, - { event_id: "$4", sender: "@bot:example.org" }, - ], - userId: "@bot:example.org", - }); - - const result = await listMatrixReactions("!room:example.org", "$msg", { client, limit: 2.9 }); - - expect(doRequest).toHaveBeenCalledWith( - "GET", - expect.stringContaining("/rooms/!room%3Aexample.org/relations/%24msg/"), - expect.objectContaining({ limit: 2 }), - ); - expect(result).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: "👍", - count: 2, - users: expect.arrayContaining(["@alice:example.org", "@bob:example.org"]), - }), - expect.objectContaining({ - key: "👎", - count: 1, - users: ["@alice:example.org"], - }), - ]), - ); - }); - - it("removes only current-user reactions matching emoji filter", async () => { - const { client, redactEvent } = createReactionsClient({ - chunk: [ - { event_id: "$1", sender: "@me:example.org", key: "👍" }, - { event_id: "$2", sender: "@me:example.org", key: "👎" }, - { event_id: "$3", sender: "@other:example.org", key: "👍" }, - ], - userId: "@me:example.org", - }); - - const result = await removeMatrixReactions("!room:example.org", "$msg", { - client, - emoji: "👍", - }); - - expect(result).toEqual({ removed: 1 }); - expect(redactEvent).toHaveBeenCalledTimes(1); - expect(redactEvent).toHaveBeenCalledWith("!room:example.org", "$1"); - }); - - it("returns removed=0 when current user id is unavailable", async () => { - const { client, redactEvent } = createReactionsClient({ - chunk: [{ event_id: "$1", sender: "@me:example.org", key: "👍" }], - userId: null, - }); - - const result = await removeMatrixReactions("!room:example.org", "$msg", { client }); - - expect(result).toEqual({ removed: 0 }); - expect(redactEvent).not.toHaveBeenCalled(); - }); - - it("returns an empty list when the relations response is malformed", async () => { - const doRequest = vi.fn(async () => ({ chunk: null })); - const client = { - doRequest, - getUserId: vi.fn(async () => "@me:example.org"), - redactEvent: vi.fn(async () => undefined), - stop: vi.fn(), - } as unknown as MatrixClient; - - const result = await listMatrixReactions("!room:example.org", "$msg", { client }); - - expect(result).toEqual([]); - }); - - it("rejects blank message ids before querying Matrix relations", async () => { - const { client, doRequest } = createReactionsClient({ - chunk: [], - userId: "@me:example.org", - }); - - await expect(listMatrixReactions("!room:example.org", " ", { client })).rejects.toThrow( - "messageId", - ); - expect(doRequest).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/matrix-js/src/matrix/actions/reactions.ts b/extensions/matrix-js/src/matrix/actions/reactions.ts deleted file mode 100644 index 3be838198f9..00000000000 --- a/extensions/matrix-js/src/matrix/actions/reactions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - buildMatrixReactionRelationsPath, - selectOwnMatrixReactionEventIds, - summarizeMatrixReactionEvents, -} from "../reaction-common.js"; -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; -import { resolveMatrixActionLimit } from "./limits.js"; -import { - type MatrixActionClientOpts, - type MatrixRawEvent, - type MatrixReactionSummary, -} from "./types.js"; - -type ActionClient = NonNullable; - -async function listMatrixReactionEvents( - client: ActionClient, - roomId: string, - messageId: string, - limit: number, -): Promise { - const res = (await client.doRequest("GET", buildMatrixReactionRelationsPath(roomId, messageId), { - dir: "b", - limit, - })) as { chunk?: MatrixRawEvent[] }; - return Array.isArray(res.chunk) ? res.chunk : []; -} - -export async function listMatrixReactions( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { limit?: number } = {}, -): Promise { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const limit = resolveMatrixActionLimit(opts.limit, 100); - const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, limit); - return summarizeMatrixReactionEvents(chunk); - }); -} - -export async function removeMatrixReactions( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { emoji?: string } = {}, -): Promise<{ removed: number }> { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const chunk = await listMatrixReactionEvents(client, resolvedRoom, messageId, 200); - const userId = await client.getUserId(); - if (!userId) { - return { removed: 0 }; - } - const toRemove = selectOwnMatrixReactionEventIds(chunk, userId, opts.emoji); - if (toRemove.length === 0) { - return { removed: 0 }; - } - await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); - return { removed: toRemove.length }; - }); -} diff --git a/extensions/matrix-js/src/matrix/actions/room.ts b/extensions/matrix-js/src/matrix/actions/room.ts deleted file mode 100644 index 8180a3dc253..00000000000 --- a/extensions/matrix-js/src/matrix/actions/room.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { resolveMatrixRoomId } from "../send.js"; -import { withResolvedActionClient } from "./client.js"; -import { EventType, type MatrixActionClientOpts } from "./types.js"; - -export async function getMatrixMemberInfo( - userId: string, - opts: MatrixActionClientOpts & { roomId?: string } = {}, -) { - return await withResolvedActionClient(opts, async (client) => { - const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; - const profile = await client.getUserProfile(userId); - // Membership and power levels are not included in profile calls; fetch state separately if needed. - return { - userId, - profile: { - displayName: profile?.displayname ?? null, - avatarUrl: profile?.avatar_url ?? null, - }, - membership: null, // Would need separate room state query - powerLevel: null, // Would need separate power levels state query - displayName: profile?.displayname ?? null, - roomId: roomId ?? null, - }; - }); -} - -export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { - return await withResolvedActionClient(opts, async (client) => { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - let name: string | null = null; - let topic: string | null = null; - let canonicalAlias: string | null = null; - let memberCount: number | null = null; - - try { - const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", ""); - name = typeof nameState?.name === "string" ? nameState.name : null; - } catch { - // ignore - } - - try { - const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, ""); - topic = typeof topicState?.topic === "string" ? topicState.topic : null; - } catch { - // ignore - } - - try { - const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", ""); - canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null; - } catch { - // ignore - } - - try { - const members = await client.getJoinedRoomMembers(resolvedRoom); - memberCount = members.length; - } catch { - // ignore - } - - return { - roomId: resolvedRoom, - name, - topic, - canonicalAlias, - altAliases: [], // Would need separate query - memberCount, - }; - }); -} diff --git a/extensions/matrix-js/src/matrix/actions/summary.ts b/extensions/matrix-js/src/matrix/actions/summary.ts deleted file mode 100644 index 5fd81401183..00000000000 --- a/extensions/matrix-js/src/matrix/actions/summary.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { MatrixClient } from "../sdk.js"; -import { - EventType, - type MatrixMessageSummary, - type MatrixRawEvent, - type RoomMessageEventContent, - type RoomPinnedEventsEventContent, -} from "./types.js"; - -export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary { - const content = event.content as RoomMessageEventContent; - const relates = content["m.relates_to"]; - let relType: string | undefined; - let eventId: string | undefined; - if (relates) { - if ("rel_type" in relates) { - relType = relates.rel_type; - eventId = relates.event_id; - } else if ("m.in_reply_to" in relates) { - eventId = relates["m.in_reply_to"]?.event_id; - } - } - const relatesTo = - relType || eventId - ? { - relType, - eventId, - } - : undefined; - return { - eventId: event.event_id, - sender: event.sender, - body: content.body, - msgtype: content.msgtype, - timestamp: event.origin_server_ts, - relatesTo, - }; -} - -export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise { - try { - const content = (await client.getRoomStateEvent( - roomId, - EventType.RoomPinnedEvents, - "", - )) as RoomPinnedEventsEventContent; - const pinned = content.pinned; - return pinned.filter((id) => id.trim().length > 0); - } catch (err: unknown) { - const errObj = err as { statusCode?: number; body?: { errcode?: string } }; - const httpStatus = errObj.statusCode; - const errcode = errObj.body?.errcode; - if (httpStatus === 404 || errcode === "M_NOT_FOUND") { - return []; - } - throw err; - } -} - -export async function fetchEventSummary( - client: MatrixClient, - roomId: string, - eventId: string, -): Promise { - try { - const raw = (await client.getEvent(roomId, eventId)) as unknown as MatrixRawEvent; - if (raw.unsigned?.redacted_because) { - return null; - } - return summarizeMatrixRawEvent(raw); - } catch { - // Event not found, redacted, or inaccessible - return null - return null; - } -} diff --git a/extensions/matrix-js/src/matrix/actions/types.ts b/extensions/matrix-js/src/matrix/actions/types.ts deleted file mode 100644 index 57b3cf6d268..00000000000 --- a/extensions/matrix-js/src/matrix/actions/types.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - MATRIX_ANNOTATION_RELATION_TYPE, - MATRIX_REACTION_EVENT_TYPE, - type MatrixReactionEventContent, - type MatrixReactionSummary, -} from "../reaction-common.js"; -import type { MatrixClient, MessageEventContent } from "../sdk.js"; -export type { MatrixRawEvent } from "../sdk.js"; - -export const MsgType = { - Text: "m.text", -} as const; - -export const RelationType = { - Replace: "m.replace", - Annotation: MATRIX_ANNOTATION_RELATION_TYPE, -} as const; - -export const EventType = { - RoomMessage: "m.room.message", - RoomPinnedEvents: "m.room.pinned_events", - RoomTopic: "m.room.topic", - Reaction: MATRIX_REACTION_EVENT_TYPE, -} as const; - -export type RoomMessageEventContent = MessageEventContent & { - msgtype: string; - body: string; - "m.new_content"?: RoomMessageEventContent; - "m.relates_to"?: { - rel_type?: string; - event_id?: string; - "m.in_reply_to"?: { event_id?: string }; - }; -}; - -export type ReactionEventContent = MatrixReactionEventContent; - -export type RoomPinnedEventsEventContent = { - pinned: string[]; -}; - -export type RoomTopicEventContent = { - topic?: string; -}; - -export type MatrixActionClientOpts = { - client?: MatrixClient; - timeoutMs?: number; - accountId?: string | null; -}; - -export type MatrixMessageSummary = { - eventId?: string; - sender?: string; - body?: string; - msgtype?: string; - timestamp?: number; - relatesTo?: { - relType?: string; - eventId?: string; - key?: string; - }; -}; - -export type MatrixActionClient = { - client: MatrixClient; - stopOnDone: boolean; -}; diff --git a/extensions/matrix-js/src/matrix/active-client.ts b/extensions/matrix-js/src/matrix/active-client.ts deleted file mode 100644 index 990acb6f116..00000000000 --- a/extensions/matrix-js/src/matrix/active-client.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { MatrixClient } from "./sdk.js"; - -const activeClients = new Map(); - -function resolveAccountKey(accountId?: string | null): string { - const normalized = normalizeAccountId(accountId); - return normalized || DEFAULT_ACCOUNT_ID; -} - -export function setActiveMatrixClient( - client: MatrixClient | null, - accountId?: string | null, -): void { - const key = resolveAccountKey(accountId); - if (!client) { - activeClients.delete(key); - return; - } - activeClients.set(key, client); -} - -export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null { - const key = resolveAccountKey(accountId); - return activeClients.get(key) ?? null; -} diff --git a/extensions/matrix-js/src/matrix/client-bootstrap.ts b/extensions/matrix-js/src/matrix/client-bootstrap.ts deleted file mode 100644 index 66512291945..00000000000 --- a/extensions/matrix-js/src/matrix/client-bootstrap.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createMatrixClient } from "./client.js"; - -type MatrixClientBootstrapAuth = { - homeserver: string; - userId: string; - accessToken: string; - encryption?: boolean; -}; - -type MatrixCryptoPrepare = { - prepare: (rooms?: string[]) => Promise; -}; - -type MatrixBootstrapClient = Awaited>; - -export async function createPreparedMatrixClient(opts: { - auth: MatrixClientBootstrapAuth; - 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: opts.accountId, - }); - if (opts.auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms); - } catch { - // Ignore crypto prep failures for one-off requests. - } - } - await client.start(); - return client; -} diff --git a/extensions/matrix-js/src/matrix/client.test.ts b/extensions/matrix-js/src/matrix/client.test.ts deleted file mode 100644 index d01974626ac..00000000000 --- a/extensions/matrix-js/src/matrix/client.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { CoreConfig } from "../types.js"; -import { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js"; -import * as credentialsModule from "./credentials.js"; -import * as sdkModule from "./sdk.js"; - -const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn()); - -vi.mock("./credentials.js", () => ({ - loadMatrixCredentials: vi.fn(() => null), - saveMatrixCredentials: saveMatrixCredentialsMock, - credentialsMatchConfig: vi.fn(() => false), - touchMatrixCredentials: vi.fn(), -})); - -describe("resolveMatrixConfig", () => { - it("prefers config over env", () => { - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://cfg.example.org", - userId: "@cfg:example.org", - accessToken: "cfg-token", - password: "cfg-pass", - deviceName: "CfgDevice", - initialSyncLimit: 5, - }, - }, - } as CoreConfig; - const env = { - MATRIX_HOMESERVER: "https://env.example.org", - MATRIX_USER_ID: "@env:example.org", - MATRIX_ACCESS_TOKEN: "env-token", - MATRIX_PASSWORD: "env-pass", - MATRIX_DEVICE_NAME: "EnvDevice", - } as NodeJS.ProcessEnv; - const resolved = resolveMatrixConfig(cfg, env); - expect(resolved).toEqual({ - homeserver: "https://cfg.example.org", - userId: "@cfg:example.org", - accessToken: "cfg-token", - password: "cfg-pass", - deviceId: undefined, - deviceName: "CfgDevice", - initialSyncLimit: 5, - encryption: false, - }); - }); - - it("uses env when config is missing", () => { - const cfg = {} as CoreConfig; - const env = { - MATRIX_HOMESERVER: "https://env.example.org", - MATRIX_USER_ID: "@env:example.org", - MATRIX_ACCESS_TOKEN: "env-token", - MATRIX_PASSWORD: "env-pass", - MATRIX_DEVICE_ID: "ENVDEVICE", - MATRIX_DEVICE_NAME: "EnvDevice", - } as NodeJS.ProcessEnv; - const resolved = resolveMatrixConfig(cfg, env); - expect(resolved.homeserver).toBe("https://env.example.org"); - expect(resolved.userId).toBe("@env:example.org"); - expect(resolved.accessToken).toBe("env-token"); - expect(resolved.password).toBe("env-pass"); - expect(resolved.deviceId).toBe("ENVDEVICE"); - expect(resolved.deviceName).toBe("EnvDevice"); - expect(resolved.initialSyncLimit).toBeUndefined(); - expect(resolved.encryption).toBe(false); - }); - - it("uses account-scoped env vars for non-default accounts before global env", () => { - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://base.example.org", - }, - }, - } as CoreConfig; - const env = { - MATRIX_HOMESERVER: "https://global.example.org", - MATRIX_ACCESS_TOKEN: "global-token", - MATRIX_OPS_HOMESERVER: "https://ops.example.org", - MATRIX_OPS_ACCESS_TOKEN: "ops-token", - MATRIX_OPS_DEVICE_NAME: "Ops Device", - } as NodeJS.ProcessEnv; - - const resolved = resolveMatrixConfigForAccount(cfg, "ops", env); - expect(resolved.homeserver).toBe("https://ops.example.org"); - expect(resolved.accessToken).toBe("ops-token"); - expect(resolved.deviceName).toBe("Ops Device"); - }); -}); - -describe("resolveMatrixAuth", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - saveMatrixCredentialsMock.mockReset(); - }); - - it("uses the hardened client request path for password login and persists deviceId", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ - access_token: "tok-123", - user_id: "@bot:example.org", - device_id: "DEVICE123", - }); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(doRequestSpy).toHaveBeenCalledWith( - "POST", - "/_matrix/client/v3/login", - undefined, - expect.objectContaining({ - type: "m.login.password", - }), - ); - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( - expect.objectContaining({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - }), - expect.any(Object), - undefined, - ); - }); - - it("surfaces password login errors when account credentials are invalid", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest"); - doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password")); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - }, - }, - } as CoreConfig; - - await expect( - resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }), - ).rejects.toThrow("Invalid username or password"); - - expect(doRequestSpy).toHaveBeenCalledWith( - "POST", - "/_matrix/client/v3/login", - undefined, - expect.objectContaining({ - type: "m.login.password", - }), - ); - expect(saveMatrixCredentialsMock).not.toHaveBeenCalled(); - }); - - it("uses cached matching credentials when access token is not configured", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "cached-token", - deviceId: "CACHEDDEVICE", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - password: "secret", - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "cached-token", - deviceId: "CACHEDDEVICE", - }); - expect(saveMatrixCredentialsMock).not.toHaveBeenCalled(); - }); - - it("falls back to config deviceId when cached credentials are missing it", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(auth.deviceId).toBe("DEVICE123"); - expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( - expect.objectContaining({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - }), - expect.any(Object), - undefined, - ); - }); - - it("resolves missing whoami identity fields for token auth", async () => { - const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ - user_id: "@bot:example.org", - device_id: "DEVICE123", - }); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - accessToken: "tok-123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - }); - - expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - }); - - it("uses config deviceId with cached credentials when token is loaded from cache", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - createdAt: "2026-01-01T00:00:00.000Z", - }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); - - const cfg = { - channels: { - "matrix-js": { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - deviceId: "DEVICE123", - encryption: true, - }, - }, - } as CoreConfig; - - const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(auth).toMatchObject({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - encryption: true, - }); - }); -}); diff --git a/extensions/matrix-js/src/matrix/client.ts b/extensions/matrix-js/src/matrix/client.ts deleted file mode 100644 index 82fe95d0fed..00000000000 --- a/extensions/matrix-js/src/matrix/client.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; -export { isBunRuntime } from "./client/runtime.js"; -export { - getMatrixScopedEnvVarNames, - hasReadyMatrixEnvAuth, - resolveMatrixConfig, - resolveMatrixConfigForAccount, - resolveScopedMatrixEnvConfig, - resolveMatrixAuth, -} from "./client/config.js"; -export { createMatrixClient } from "./client/create-client.js"; -export { - resolveSharedMatrixClient, - waitForMatrixSync, - stopSharedClient, - stopSharedClientForAccount, -} from "./client/shared.js"; diff --git a/extensions/matrix-js/src/matrix/client/config.ts b/extensions/matrix-js/src/matrix/client/config.ts deleted file mode 100644 index 975ec14298d..00000000000 --- a/extensions/matrix-js/src/matrix/client/config.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig } from "../../types.js"; -import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js"; -import { MatrixClient } from "../sdk.js"; -import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; -import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; - -function clean(value?: string): string { - return value?.trim() ?? ""; -} - -type MatrixEnvConfig = { - homeserver: string; - userId: string; - accessToken?: string; - password?: string; - deviceId?: string; - deviceName?: string; -}; - -function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig { - return { - homeserver: clean(env.MATRIX_HOMESERVER), - userId: clean(env.MATRIX_USER_ID), - accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined, - password: clean(env.MATRIX_PASSWORD) || undefined, - deviceId: clean(env.MATRIX_DEVICE_ID) || undefined, - deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined, - }; -} - -function resolveMatrixEnvAccountToken(accountId: string): string { - return normalizeAccountId(accountId) - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") - .toUpperCase(); -} - -export function getMatrixScopedEnvVarNames(accountId: string): { - homeserver: string; - userId: string; - accessToken: string; - password: string; - deviceId: string; - deviceName: string; -} { - const token = resolveMatrixEnvAccountToken(accountId); - return { - homeserver: `MATRIX_${token}_HOMESERVER`, - userId: `MATRIX_${token}_USER_ID`, - accessToken: `MATRIX_${token}_ACCESS_TOKEN`, - password: `MATRIX_${token}_PASSWORD`, - deviceId: `MATRIX_${token}_DEVICE_ID`, - deviceName: `MATRIX_${token}_DEVICE_NAME`, - }; -} - -export function resolveScopedMatrixEnvConfig( - accountId: string, - env: NodeJS.ProcessEnv = process.env, -): MatrixEnvConfig { - const keys = getMatrixScopedEnvVarNames(accountId); - return { - homeserver: clean(env[keys.homeserver]), - userId: clean(env[keys.userId]), - accessToken: clean(env[keys.accessToken]) || undefined, - password: clean(env[keys.password]) || undefined, - deviceId: clean(env[keys.deviceId]) || undefined, - deviceName: clean(env[keys.deviceName]) || undefined, - }; -} - -export function hasReadyMatrixEnvAuth(config: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; -}): boolean { - const homeserver = clean(config.homeserver); - const userId = clean(config.userId); - const accessToken = clean(config.accessToken); - const password = clean(config.password); - return Boolean(homeserver && (accessToken || (userId && password))); -} - -export function resolveMatrixConfig( - cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, - env: NodeJS.ProcessEnv = process.env, -): MatrixResolvedConfig { - const matrix = resolveMatrixBaseConfig(cfg); - const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env); - const globalEnv = resolveGlobalMatrixEnvConfig(env); - const homeserver = - clean(matrix.homeserver) || defaultScopedEnv.homeserver || globalEnv.homeserver; - const userId = clean(matrix.userId) || defaultScopedEnv.userId || globalEnv.userId; - const accessToken = - clean(matrix.accessToken) || defaultScopedEnv.accessToken || globalEnv.accessToken || undefined; - const password = - clean(matrix.password) || defaultScopedEnv.password || globalEnv.password || undefined; - const deviceId = - clean(matrix.deviceId) || defaultScopedEnv.deviceId || globalEnv.deviceId || undefined; - const deviceName = - clean(matrix.deviceName) || defaultScopedEnv.deviceName || globalEnv.deviceName || undefined; - const initialSyncLimit = - typeof matrix.initialSyncLimit === "number" - ? Math.max(0, Math.floor(matrix.initialSyncLimit)) - : undefined; - const encryption = matrix.encryption ?? false; - return { - homeserver, - userId, - accessToken, - password, - deviceId, - deviceName, - initialSyncLimit, - encryption, - }; -} - -export function resolveMatrixConfigForAccount( - cfg: CoreConfig, - accountId: string, - env: NodeJS.ProcessEnv = process.env, -): MatrixResolvedConfig { - const matrix = resolveMatrixBaseConfig(cfg); - const account = findMatrixAccountConfig(cfg, accountId) ?? {}; - const normalizedAccountId = normalizeAccountId(accountId); - const scopedEnv = resolveScopedMatrixEnvConfig(normalizedAccountId, env); - const globalEnv = resolveGlobalMatrixEnvConfig(env); - - const accountHomeserver = clean( - typeof account.homeserver === "string" ? account.homeserver : undefined, - ); - const accountUserId = clean(typeof account.userId === "string" ? account.userId : undefined); - const accountAccessToken = clean( - typeof account.accessToken === "string" ? account.accessToken : undefined, - ); - const accountPassword = clean( - typeof account.password === "string" ? account.password : undefined, - ); - const accountDeviceId = clean( - typeof account.deviceId === "string" ? account.deviceId : undefined, - ); - const accountDeviceName = clean( - typeof account.deviceName === "string" ? account.deviceName : undefined, - ); - - const homeserver = - accountHomeserver || scopedEnv.homeserver || clean(matrix.homeserver) || globalEnv.homeserver; - const userId = accountUserId || scopedEnv.userId || clean(matrix.userId) || globalEnv.userId; - const accessToken = - accountAccessToken || - scopedEnv.accessToken || - clean(matrix.accessToken) || - globalEnv.accessToken || - undefined; - const password = - accountPassword || - scopedEnv.password || - clean(matrix.password) || - globalEnv.password || - undefined; - const deviceId = - accountDeviceId || - scopedEnv.deviceId || - clean(matrix.deviceId) || - globalEnv.deviceId || - undefined; - const deviceName = - accountDeviceName || - scopedEnv.deviceName || - clean(matrix.deviceName) || - globalEnv.deviceName || - undefined; - - const accountInitialSyncLimit = - typeof account.initialSyncLimit === "number" - ? Math.max(0, Math.floor(account.initialSyncLimit)) - : undefined; - const initialSyncLimit = - accountInitialSyncLimit ?? - (typeof matrix.initialSyncLimit === "number" - ? Math.max(0, Math.floor(matrix.initialSyncLimit)) - : undefined); - const encryption = - typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false); - - return { - homeserver, - userId, - accessToken, - password, - deviceId, - deviceName, - initialSyncLimit, - encryption, - }; -} - -export async function resolveMatrixAuth(params?: { - cfg?: CoreConfig; - env?: NodeJS.ProcessEnv; - accountId?: string | null; -}): Promise { - const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); - const env = params?.env ?? process.env; - const accountId = params?.accountId; - const resolved = accountId - ? resolveMatrixConfigForAccount(cfg, accountId, env) - : resolveMatrixConfig(cfg, env); - if (!resolved.homeserver) { - throw new Error("Matrix homeserver is required (matrix-js.homeserver)"); - } - - const { - loadMatrixCredentials, - saveMatrixCredentials, - credentialsMatchConfig, - touchMatrixCredentials, - } = await import("../credentials.js"); - - const cached = loadMatrixCredentials(env, accountId); - const cachedCredentials = - cached && - credentialsMatchConfig(cached, { - homeserver: resolved.homeserver, - userId: resolved.userId || "", - }) - ? cached - : null; - - // If we have an access token, we can fetch userId via whoami if not provided - if (resolved.accessToken) { - let userId = resolved.userId; - const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken; - let knownDeviceId = hasMatchingCachedToken - ? cachedCredentials?.deviceId || resolved.deviceId - : resolved.deviceId; - - if (!userId || !knownDeviceId) { - // Fetch whoami when we need to resolve userId and/or deviceId from token auth. - ensureMatrixSdkLoggingConfigured(); - const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); - const whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { - user_id?: string; - device_id?: string; - }; - if (!userId) { - const fetchedUserId = whoami.user_id?.trim(); - if (!fetchedUserId) { - throw new Error("Matrix whoami did not return user_id"); - } - userId = fetchedUserId; - } - if (!knownDeviceId) { - knownDeviceId = whoami.device_id?.trim() || resolved.deviceId; - } - } - - const shouldRefreshCachedCredentials = - !cachedCredentials || - !hasMatchingCachedToken || - cachedCredentials.userId !== userId || - (cachedCredentials.deviceId || undefined) !== knownDeviceId; - if (shouldRefreshCachedCredentials) { - await saveMatrixCredentials( - { - homeserver: resolved.homeserver, - userId, - accessToken: resolved.accessToken, - deviceId: knownDeviceId, - }, - env, - accountId, - ); - } else if (hasMatchingCachedToken) { - await touchMatrixCredentials(env, accountId); - } - return { - homeserver: resolved.homeserver, - userId, - accessToken: resolved.accessToken, - password: resolved.password, - deviceId: knownDeviceId, - deviceName: resolved.deviceName, - initialSyncLimit: resolved.initialSyncLimit, - encryption: resolved.encryption, - }; - } - - if (cachedCredentials) { - await touchMatrixCredentials(env, accountId); - return { - homeserver: cachedCredentials.homeserver, - userId: cachedCredentials.userId, - accessToken: cachedCredentials.accessToken, - password: resolved.password, - deviceId: cachedCredentials.deviceId || resolved.deviceId, - deviceName: resolved.deviceName, - initialSyncLimit: resolved.initialSyncLimit, - encryption: resolved.encryption, - }; - } - - if (!resolved.userId) { - throw new Error( - "Matrix userId is required when no access token is configured (matrix-js.userId)", - ); - } - - if (!resolved.password) { - throw new Error( - "Matrix password is required when no access token is configured (matrix-js.password)", - ); - } - - // Login with password using the same hardened request path as other Matrix HTTP calls. - ensureMatrixSdkLoggingConfigured(); - const loginClient = new MatrixClient(resolved.homeserver, ""); - const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { - type: "m.login.password", - identifier: { type: "m.id.user", user: resolved.userId }, - password: resolved.password, - device_id: resolved.deviceId, - initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", - })) as { - access_token?: string; - user_id?: string; - device_id?: string; - }; - - const accessToken = login.access_token?.trim(); - if (!accessToken) { - throw new Error("Matrix login did not return an access token"); - } - - const auth: MatrixAuth = { - homeserver: resolved.homeserver, - userId: login.user_id ?? resolved.userId, - accessToken, - password: resolved.password, - deviceId: login.device_id ?? resolved.deviceId, - deviceName: resolved.deviceName, - initialSyncLimit: resolved.initialSyncLimit, - encryption: resolved.encryption, - }; - - await saveMatrixCredentials( - { - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - deviceId: auth.deviceId, - }, - env, - accountId, - ); - - return auth; -} diff --git a/extensions/matrix-js/src/matrix/client/create-client.ts b/extensions/matrix-js/src/matrix/client/create-client.ts deleted file mode 100644 index 1782f4ba9cc..00000000000 --- a/extensions/matrix-js/src/matrix/client/create-client.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from "node:fs"; -import { MatrixClient } from "../sdk.js"; -import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; -import { - maybeMigrateLegacyStorage, - resolveMatrixStoragePaths, - writeStorageMeta, -} from "./storage.js"; - -export async function createMatrixClient(params: { - homeserver: string; - userId?: string; - accessToken: string; - password?: string; - deviceId?: string; - encryption?: boolean; - localTimeoutMs?: number; - initialSyncLimit?: number; - accountId?: string | null; - autoBootstrapCrypto?: boolean; -}): Promise { - ensureMatrixSdkLoggingConfigured(); - const env = process.env; - const userId = params.userId?.trim() || "unknown"; - const matrixClientUserId = params.userId?.trim() || undefined; - - const storagePaths = resolveMatrixStoragePaths({ - homeserver: params.homeserver, - userId, - accessToken: params.accessToken, - accountId: params.accountId, - env, - }); - maybeMigrateLegacyStorage({ - storagePaths, - homeserver: params.homeserver, - userId, - accessToken: params.accessToken, - accountId: params.accountId, - env, - }); - fs.mkdirSync(storagePaths.rootDir, { recursive: true }); - - writeStorageMeta({ - storagePaths, - homeserver: params.homeserver, - userId, - accountId: params.accountId, - }); - - const cryptoDatabasePrefix = `openclaw-matrix-js-${storagePaths.accountKey}-${storagePaths.tokenHash}`; - - return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, { - userId: matrixClientUserId, - password: params.password, - deviceId: params.deviceId, - encryption: params.encryption, - localTimeoutMs: params.localTimeoutMs, - initialSyncLimit: params.initialSyncLimit, - recoveryKeyPath: storagePaths.recoveryKeyPath, - idbSnapshotPath: storagePaths.idbSnapshotPath, - cryptoDatabasePrefix, - autoBootstrapCrypto: params.autoBootstrapCrypto, - }); -} diff --git a/extensions/matrix-js/src/matrix/client/logging.ts b/extensions/matrix-js/src/matrix/client/logging.ts deleted file mode 100644 index be50656497f..00000000000 --- a/extensions/matrix-js/src/matrix/client/logging.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ConsoleLogger, LogService } from "../sdk/logger.js"; - -let matrixSdkLoggingConfigured = false; -let matrixSdkLogMode: "default" | "quiet" = "default"; -const matrixSdkBaseLogger = new ConsoleLogger(); - -type MatrixJsSdkLogger = { - trace: (...messageOrObject: unknown[]) => void; - debug: (...messageOrObject: unknown[]) => void; - info: (...messageOrObject: unknown[]) => void; - warn: (...messageOrObject: unknown[]) => void; - error: (...messageOrObject: unknown[]) => void; - getChild: (namespace: string) => MatrixJsSdkLogger; -}; - -function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { - if (!module.includes("MatrixHttpClient")) { - return false; - } - return messageOrObject.some((entry) => { - if (!entry || typeof entry !== "object") { - return false; - } - return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; - }); -} - -export function ensureMatrixSdkLoggingConfigured(): void { - if (!matrixSdkLoggingConfigured) { - matrixSdkLoggingConfigured = true; - } - applyMatrixSdkLogger(); -} - -export function setMatrixSdkLogMode(mode: "default" | "quiet"): void { - matrixSdkLogMode = mode; - if (!matrixSdkLoggingConfigured) { - return; - } - applyMatrixSdkLogger(); -} - -export function createMatrixJsSdkClientLogger(prefix = "matrix-js"): MatrixJsSdkLogger { - return createMatrixJsSdkLoggerInstance(prefix); -} - -function applyMatrixSdkLogger(): void { - if (matrixSdkLogMode === "quiet") { - LogService.setLogger({ - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - }); - return; - } - - LogService.setLogger({ - trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), - debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), - info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), - warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), - error: (module, ...messageOrObject) => { - if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { - return; - } - matrixSdkBaseLogger.error(module, ...messageOrObject); - }, - }); -} - -function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger { - const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => { - if (matrixSdkLogMode === "quiet") { - return; - } - (matrixSdkBaseLogger[method] as (module: string, ...args: unknown[]) => void)( - prefix, - ...messageOrObject, - ); - }; - - return { - trace: (...messageOrObject) => log("trace", ...messageOrObject), - debug: (...messageOrObject) => log("debug", ...messageOrObject), - info: (...messageOrObject) => log("info", ...messageOrObject), - warn: (...messageOrObject) => log("warn", ...messageOrObject), - error: (...messageOrObject) => { - if (shouldSuppressMatrixHttpNotFound(prefix, messageOrObject)) { - return; - } - log("error", ...messageOrObject); - }, - getChild: (namespace: string) => { - const nextNamespace = namespace.trim(); - return createMatrixJsSdkLoggerInstance(nextNamespace ? `${prefix}.${nextNamespace}` : prefix); - }, - }; -} diff --git a/extensions/matrix-js/src/matrix/client/runtime.ts b/extensions/matrix-js/src/matrix/client/runtime.ts deleted file mode 100644 index 4995eaf8d5c..00000000000 --- a/extensions/matrix-js/src/matrix/client/runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function isBunRuntime(): boolean { - const versions = process.versions as { bun?: string }; - return typeof versions.bun === "string"; -} diff --git a/extensions/matrix-js/src/matrix/client/shared.test.ts b/extensions/matrix-js/src/matrix/client/shared.test.ts deleted file mode 100644 index 72b708f3c93..00000000000 --- a/extensions/matrix-js/src/matrix/client/shared.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixAuth } from "./types.js"; - -const resolveMatrixAuthMock = vi.hoisted(() => vi.fn()); -const createMatrixClientMock = vi.hoisted(() => vi.fn()); - -vi.mock("./config.js", () => ({ - resolveMatrixAuth: resolveMatrixAuthMock, -})); - -vi.mock("./create-client.js", () => ({ - createMatrixClient: createMatrixClientMock, -})); - -import { - resolveSharedMatrixClient, - stopSharedClient, - stopSharedClientForAccount, -} from "./shared.js"; - -function authFor(accountId: string): MatrixAuth { - return { - homeserver: "https://matrix.example.org", - userId: `@${accountId}:example.org`, - accessToken: `token-${accountId}`, - password: "secret", - deviceId: `${accountId.toUpperCase()}-DEVICE`, - deviceName: `${accountId} device`, - initialSyncLimit: undefined, - encryption: false, - }; -} - -function createMockClient(name: string) { - const client = { - name, - start: vi.fn(async () => undefined), - stop: vi.fn(() => undefined), - getJoinedRooms: vi.fn(async () => [] as string[]), - crypto: undefined, - }; - return client; -} - -describe("resolveSharedMatrixClient", () => { - beforeEach(() => { - resolveMatrixAuthMock.mockReset(); - createMatrixClientMock.mockReset(); - }); - - afterEach(() => { - stopSharedClient(); - vi.clearAllMocks(); - }); - - it("keeps account clients isolated when resolves are interleaved", async () => { - const mainAuth = authFor("main"); - const poeAuth = authFor("ops"); - const mainClient = createMockClient("main"); - const poeClient = createMockClient("ops"); - - resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) => - accountId === "ops" ? poeAuth : mainAuth, - ); - createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => { - if (accountId === "ops") { - return poeClient; - } - return mainClient; - }); - - const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false }); - const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false }); - const secondMain = await resolveSharedMatrixClient({ accountId: "main" }); - - expect(firstMain).toBe(mainClient); - expect(firstPoe).toBe(poeClient); - expect(secondMain).toBe(mainClient); - expect(createMatrixClientMock).toHaveBeenCalledTimes(2); - expect(mainClient.start).toHaveBeenCalledTimes(1); - expect(poeClient.start).toHaveBeenCalledTimes(0); - }); - - it("stops only the targeted account client", async () => { - const mainAuth = authFor("main"); - const poeAuth = authFor("ops"); - const mainClient = createMockClient("main"); - const poeClient = createMockClient("ops"); - - resolveMatrixAuthMock.mockImplementation(async ({ accountId }: { accountId?: string }) => - accountId === "ops" ? poeAuth : mainAuth, - ); - createMatrixClientMock.mockImplementation(async ({ accountId }: { accountId?: string }) => { - if (accountId === "ops") { - return poeClient; - } - return mainClient; - }); - - await resolveSharedMatrixClient({ accountId: "main", startClient: false }); - await resolveSharedMatrixClient({ accountId: "ops", startClient: false }); - - stopSharedClientForAccount(mainAuth, "main"); - - expect(mainClient.stop).toHaveBeenCalledTimes(1); - expect(poeClient.stop).toHaveBeenCalledTimes(0); - - stopSharedClient(); - - expect(poeClient.stop).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/matrix-js/src/matrix/client/shared.ts b/extensions/matrix-js/src/matrix/client/shared.ts deleted file mode 100644 index fd7c76995f1..00000000000 --- a/extensions/matrix-js/src/matrix/client/shared.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { CoreConfig } from "../../types.js"; -import type { MatrixClient } from "../sdk.js"; -import { LogService } from "../sdk/logger.js"; -import { resolveMatrixAuth } from "./config.js"; -import { createMatrixClient } from "./create-client.js"; -import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; -import type { MatrixAuth } from "./types.js"; - -type SharedMatrixClientState = { - client: MatrixClient; - key: string; - started: boolean; - cryptoReady: boolean; - startPromise: Promise | null; -}; - -const sharedClientStates = new Map(); -const sharedClientPromises = new Map>(); - -function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { - return [ - auth.homeserver, - auth.userId, - auth.accessToken, - auth.encryption ? "e2ee" : "plain", - accountId ?? DEFAULT_ACCOUNT_KEY, - ].join("|"); -} - -async function createSharedMatrixClient(params: { - auth: MatrixAuth; - timeoutMs?: number; - accountId?: string | null; -}): Promise { - const client = await createMatrixClient({ - homeserver: params.auth.homeserver, - userId: params.auth.userId, - accessToken: params.auth.accessToken, - password: params.auth.password, - deviceId: params.auth.deviceId, - encryption: params.auth.encryption, - localTimeoutMs: params.timeoutMs, - initialSyncLimit: params.auth.initialSyncLimit, - accountId: params.accountId, - }); - return { - client, - key: buildSharedClientKey(params.auth, params.accountId), - started: false, - cryptoReady: false, - startPromise: null, - }; -} - -async function ensureSharedClientStarted(params: { - state: SharedMatrixClientState; - timeoutMs?: number; - initialSyncLimit?: number; - encryption?: boolean; -}): Promise { - if (params.state.started) { - return; - } - if (params.state.startPromise) { - await params.state.startPromise; - return; - } - - params.state.startPromise = (async () => { - const client = params.state.client; - - // Initialize crypto if enabled - if (params.encryption && !params.state.cryptoReady) { - try { - const joinedRooms = await client.getJoinedRooms(); - if (client.crypto) { - await client.crypto.prepare(joinedRooms); - params.state.cryptoReady = true; - } - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); - } - } - - await client.start(); - params.state.started = true; - })(); - - try { - await params.state.startPromise; - } finally { - params.state.startPromise = null; - } -} - -export async function resolveSharedMatrixClient( - params: { - cfg?: CoreConfig; - env?: NodeJS.ProcessEnv; - timeoutMs?: number; - auth?: MatrixAuth; - startClient?: boolean; - accountId?: string | null; - } = {}, -): Promise { - const auth = - params.auth ?? - (await resolveMatrixAuth({ - cfg: params.cfg, - env: params.env, - accountId: params.accountId, - })); - const key = buildSharedClientKey(auth, params.accountId); - const shouldStart = params.startClient !== false; - - const existingState = sharedClientStates.get(key); - if (existingState) { - if (shouldStart) { - await ensureSharedClientStarted({ - state: existingState, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return existingState.client; - } - - const existingPromise = sharedClientPromises.get(key); - if (existingPromise) { - const pending = await existingPromise; - if (shouldStart) { - await ensureSharedClientStarted({ - state: pending, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return pending.client; - } - - const creationPromise = createSharedMatrixClient({ - auth, - timeoutMs: params.timeoutMs, - accountId: params.accountId, - }); - sharedClientPromises.set(key, creationPromise); - - try { - const created = await creationPromise; - sharedClientStates.set(key, created); - if (shouldStart) { - await ensureSharedClientStarted({ - state: created, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return created.client; - } finally { - sharedClientPromises.delete(key); - } -} - -export async function waitForMatrixSync(_params: { - client: MatrixClient; - timeoutMs?: number; - abortSignal?: AbortSignal; -}): Promise { - // matrix-js-sdk handles sync lifecycle in start() for this integration. - // This is kept for API compatibility but is essentially a no-op now -} - -export function stopSharedClient(): void { - for (const state of sharedClientStates.values()) { - state.client.stop(); - } - sharedClientStates.clear(); - sharedClientPromises.clear(); -} - -export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void { - const key = buildSharedClientKey(auth, accountId); - const state = sharedClientStates.get(key); - if (!state) { - return; - } - state.client.stop(); - sharedClientStates.delete(key); - sharedClientPromises.delete(key); -} diff --git a/extensions/matrix-js/src/matrix/client/storage.ts b/extensions/matrix-js/src/matrix/client/storage.ts deleted file mode 100644 index cd60989f9e7..00000000000 --- a/extensions/matrix-js/src/matrix/client/storage.ts +++ /dev/null @@ -1,182 +0,0 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { MatrixStoragePaths } from "./types.js"; - -export const DEFAULT_ACCOUNT_KEY = "default"; -const STORAGE_META_FILENAME = "storage-meta.json"; - -function sanitizePathSegment(value: string): string { - const cleaned = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return cleaned || "unknown"; -} - -function resolveHomeserverKey(homeserver: string): string { - try { - const url = new URL(homeserver); - if (url.host) { - return sanitizePathSegment(url.host); - } - } catch { - // fall through - } - return sanitizePathSegment(homeserver); -} - -function hashAccessToken(accessToken: string): string { - return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); -} - -function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { - storagePath: string; - cryptoPath: string; -} { - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return { - storagePath: path.join(stateDir, "credentials", "matrix-js", "bot-storage.json"), - cryptoPath: path.join(stateDir, "credentials", "matrix-js", "crypto"), - }; -} - -function resolveLegacyMatrixJsAccountRoot(params: { - homeserver: string; - userId: string; - accessToken: string; - accountId?: string | null; - env?: NodeJS.ProcessEnv; -}): string { - const env = params.env ?? process.env; - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); - const userKey = sanitizePathSegment(params.userId); - const serverKey = resolveHomeserverKey(params.homeserver); - const tokenHash = hashAccessToken(params.accessToken); - return path.join( - stateDir, - "credentials", - "matrix-js", - "accounts", - accountKey, - `${serverKey}__${userKey}`, - tokenHash, - ); -} - -export function resolveMatrixStoragePaths(params: { - homeserver: string; - userId: string; - accessToken: string; - accountId?: string | null; - env?: NodeJS.ProcessEnv; -}): MatrixStoragePaths { - const env = params.env ?? process.env; - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); - const userKey = sanitizePathSegment(params.userId); - const serverKey = resolveHomeserverKey(params.homeserver); - const tokenHash = hashAccessToken(params.accessToken); - const rootDir = path.join( - stateDir, - "matrix", - "accounts", - accountKey, - `${serverKey}__${userKey}`, - tokenHash, - ); - return { - rootDir, - storagePath: path.join(rootDir, "bot-storage.json"), - cryptoPath: path.join(rootDir, "crypto"), - metaPath: path.join(rootDir, STORAGE_META_FILENAME), - recoveryKeyPath: path.join(rootDir, "recovery-key.json"), - idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"), - accountKey, - tokenHash, - }; -} - -export function maybeMigrateLegacyStorage(params: { - storagePaths: MatrixStoragePaths; - homeserver?: string; - userId?: string; - accessToken?: string; - accountId?: string | null; - env?: NodeJS.ProcessEnv; -}): void { - const hasNewStorage = - fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath); - if (hasNewStorage) { - return; - } - - const legacyAccountRoot = - params.homeserver && params.userId && params.accessToken - ? resolveLegacyMatrixJsAccountRoot({ - homeserver: params.homeserver, - userId: params.userId, - accessToken: params.accessToken, - accountId: params.accountId, - env: params.env, - }) - : null; - - if (legacyAccountRoot && fs.existsSync(legacyAccountRoot)) { - fs.mkdirSync(path.dirname(params.storagePaths.rootDir), { recursive: true }); - try { - fs.renameSync(legacyAccountRoot, params.storagePaths.rootDir); - return; - } catch { - // Fall through to older one-off migration paths. - } - } - - const legacy = resolveLegacyStoragePaths(params.env); - const hasLegacyStorage = fs.existsSync(legacy.storagePath); - const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); - if (!hasLegacyStorage && !hasLegacyCrypto) { - return; - } - - fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); - if (hasLegacyStorage) { - try { - fs.renameSync(legacy.storagePath, params.storagePaths.storagePath); - } catch { - // Ignore migration failures; new store will be created. - } - } - if (hasLegacyCrypto) { - try { - fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath); - } catch { - // Ignore migration failures; new store will be created. - } - } -} - -export function writeStorageMeta(params: { - storagePaths: MatrixStoragePaths; - homeserver: string; - userId: string; - accountId?: string | null; -}): void { - try { - const payload = { - homeserver: params.homeserver, - userId: params.userId, - accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, - accessTokenHash: params.storagePaths.tokenHash, - createdAt: new Date().toISOString(), - }; - fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); - fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8"); - } catch { - // ignore meta write failures - } -} diff --git a/extensions/matrix-js/src/matrix/client/types.ts b/extensions/matrix-js/src/matrix/client/types.ts deleted file mode 100644 index 4a6bac48a40..00000000000 --- a/extensions/matrix-js/src/matrix/client/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type MatrixResolvedConfig = { - homeserver: string; - userId: string; - accessToken?: string; - deviceId?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - encryption?: boolean; -}; - -/** - * Authenticated Matrix configuration. - * Note: deviceId is NOT included here because it's implicit in the accessToken. - * The crypto storage assumes the device ID (and thus access token) does not change - * between restarts. If the access token becomes invalid or crypto storage is lost, - * both will need to be recreated together. - */ -export type MatrixAuth = { - homeserver: string; - userId: string; - accessToken: string; - password?: string; - deviceId?: string; - deviceName?: string; - initialSyncLimit?: number; - encryption?: boolean; -}; - -export type MatrixStoragePaths = { - rootDir: string; - storagePath: string; - cryptoPath: string; - metaPath: string; - recoveryKeyPath: string; - idbSnapshotPath: string; - accountKey: string; - tokenHash: string; -}; diff --git a/extensions/matrix-js/src/matrix/credentials.ts b/extensions/matrix-js/src/matrix/credentials.ts deleted file mode 100644 index 111c8bfd458..00000000000 --- a/extensions/matrix-js/src/matrix/credentials.ts +++ /dev/null @@ -1,166 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js"; -import { getMatrixRuntime } from "../runtime.js"; - -export type MatrixStoredCredentials = { - homeserver: string; - userId: string; - accessToken: string; - deviceId?: string; - createdAt: string; - lastUsedAt?: string; -}; - -function credentialsFilename(accountId?: string | null): string { - const normalized = normalizeAccountId(accountId); - if (normalized === DEFAULT_ACCOUNT_ID) { - return "credentials.json"; - } - // normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe. - // Different raw IDs that normalize to the same value are the same logical account. - return `credentials-${normalized}.json`; -} - -export function resolveMatrixCredentialsDir( - env: NodeJS.ProcessEnv = process.env, - stateDir?: string, -): string { - const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return path.join(resolvedStateDir, "credentials", "matrix"); -} - -function resolveLegacyMatrixJsCredentialsDir( - env: NodeJS.ProcessEnv = process.env, - stateDir?: string, -): string { - const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return path.join(resolvedStateDir, "credentials", "matrix-js"); -} - -export function resolveMatrixCredentialsPath( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): string { - const dir = resolveMatrixCredentialsDir(env); - return path.join(dir, credentialsFilename(accountId)); -} - -function resolveLegacyMatrixJsCredentialsPath( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): string { - const dir = resolveLegacyMatrixJsCredentialsDir(env); - return path.join(dir, credentialsFilename(accountId)); -} - -function maybeMigrateLegacyMatrixJsCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): void { - const nextPath = resolveMatrixCredentialsPath(env, accountId); - if (fs.existsSync(nextPath)) { - return; - } - const legacyPath = resolveLegacyMatrixJsCredentialsPath(env, accountId); - if (!fs.existsSync(legacyPath)) { - return; - } - fs.mkdirSync(path.dirname(nextPath), { recursive: true }); - try { - fs.renameSync(legacyPath, nextPath); - } catch { - // Best-effort compatibility migration only. - } -} - -export function loadMatrixCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): MatrixStoredCredentials | null { - maybeMigrateLegacyMatrixJsCredentials(env, accountId); - const credPath = resolveMatrixCredentialsPath(env, accountId); - try { - if (!fs.existsSync(credPath)) { - return null; - } - const raw = fs.readFileSync(credPath, "utf-8"); - const parsed = JSON.parse(raw) as Partial; - if ( - typeof parsed.homeserver !== "string" || - typeof parsed.userId !== "string" || - typeof parsed.accessToken !== "string" - ) { - return null; - } - return parsed as MatrixStoredCredentials; - } catch { - return null; - } -} - -export async function saveMatrixCredentials( - credentials: Omit, - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): Promise { - maybeMigrateLegacyMatrixJsCredentials(env, accountId); - const credPath = resolveMatrixCredentialsPath(env, accountId); - - const existing = loadMatrixCredentials(env, accountId); - const now = new Date().toISOString(); - - const toSave: MatrixStoredCredentials = { - ...credentials, - createdAt: existing?.createdAt ?? now, - lastUsedAt: now, - }; - - await writeJsonFileAtomically(credPath, toSave); -} - -export async function touchMatrixCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): Promise { - maybeMigrateLegacyMatrixJsCredentials(env, accountId); - const existing = loadMatrixCredentials(env, accountId); - if (!existing) { - return; - } - - existing.lastUsedAt = new Date().toISOString(); - const credPath = resolveMatrixCredentialsPath(env, accountId); - await writeJsonFileAtomically(credPath, existing); -} - -export function clearMatrixCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): void { - const credPath = resolveMatrixCredentialsPath(env, accountId); - const legacyPath = resolveLegacyMatrixJsCredentialsPath(env, accountId); - try { - if (fs.existsSync(credPath)) { - fs.unlinkSync(credPath); - } - if (fs.existsSync(legacyPath)) { - fs.unlinkSync(legacyPath); - } - } catch { - // ignore - } -} - -export function credentialsMatchConfig( - stored: MatrixStoredCredentials, - config: { homeserver: string; userId: string }, -): boolean { - // If userId is empty (token-based auth), only match homeserver - if (!config.userId) { - return stored.homeserver === config.homeserver; - } - return stored.homeserver === config.homeserver && stored.userId === config.userId; -} diff --git a/extensions/matrix-js/src/matrix/deps.ts b/extensions/matrix-js/src/matrix/deps.ts deleted file mode 100644 index 6585c7420d2..00000000000 --- a/extensions/matrix-js/src/matrix/deps.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { spawn } from "node:child_process"; -import fs from "node:fs"; -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js"; - -const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"]; - -function resolveMissingMatrixPackages(): string[] { - try { - const req = createRequire(import.meta.url); - return REQUIRED_MATRIX_PACKAGES.filter((pkg) => { - try { - req.resolve(pkg); - return false; - } catch { - return true; - } - }); - } catch { - return [...REQUIRED_MATRIX_PACKAGES]; - } -} - -export function isMatrixSdkAvailable(): boolean { - return resolveMissingMatrixPackages().length === 0; -} - -function resolvePluginRoot(): string { - const currentDir = path.dirname(fileURLToPath(import.meta.url)); - return path.resolve(currentDir, "..", ".."); -} - -type CommandResult = { - code: number; - stdout: string; - stderr: string; -}; - -async function runFixedCommandWithTimeout(params: { - argv: string[]; - cwd: string; - timeoutMs: number; - env?: NodeJS.ProcessEnv; -}): Promise { - return await new Promise((resolve) => { - const [command, ...args] = params.argv; - if (!command) { - resolve({ - code: 1, - stdout: "", - stderr: "command is required", - }); - return; - } - - const proc = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, ...params.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const finalize = (result: CommandResult) => { - if (settled) { - return; - } - settled = true; - if (timer) { - clearTimeout(timer); - } - resolve(result); - }; - - proc.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - proc.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - - timer = setTimeout(() => { - proc.kill("SIGKILL"); - finalize({ - code: 124, - stdout, - stderr: stderr || `command timed out after ${params.timeoutMs}ms`, - }); - }, params.timeoutMs); - - proc.on("error", (err) => { - finalize({ - code: 1, - stdout, - stderr: err.message, - }); - }); - - proc.on("close", (code) => { - finalize({ - code: code ?? 1, - stdout, - stderr, - }); - }); - }); -} - -export async function ensureMatrixSdkInstalled(params: { - runtime: RuntimeEnv; - confirm?: (message: string) => Promise; -}): Promise { - if (isMatrixSdkAvailable()) { - return; - } - const confirm = params.confirm; - if (confirm) { - const ok = await confirm( - "Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?", - ); - if (!ok) { - throw new Error( - "Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).", - ); - } - } - - const root = resolvePluginRoot(); - const command = fs.existsSync(path.join(root, "pnpm-lock.yaml")) - ? ["pnpm", "install"] - : ["npm", "install", "--omit=dev", "--silent"]; - params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); - const result = await runFixedCommandWithTimeout({ - argv: command, - cwd: root, - timeoutMs: 300_000, - env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, - }); - if (result.code !== 0) { - throw new Error( - result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.", - ); - } - if (!isMatrixSdkAvailable()) { - const missing = resolveMissingMatrixPackages(); - throw new Error( - missing.length > 0 - ? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}` - : "Matrix dependency install completed but Matrix dependencies are still missing.", - ); - } -} diff --git a/extensions/matrix-js/src/matrix/format.test.ts b/extensions/matrix-js/src/matrix/format.test.ts deleted file mode 100644 index 4538c2792e2..00000000000 --- a/extensions/matrix-js/src/matrix/format.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { markdownToMatrixHtml } from "./format.js"; - -describe("markdownToMatrixHtml", () => { - it("renders basic inline formatting", () => { - const html = markdownToMatrixHtml("hi _there_ **boss** `code`"); - expect(html).toContain("there"); - expect(html).toContain("boss"); - expect(html).toContain("code"); - }); - - it("renders links as HTML", () => { - const html = markdownToMatrixHtml("see [docs](https://example.com)"); - expect(html).toContain('docs'); - }); - - it("escapes raw HTML", () => { - const html = markdownToMatrixHtml("nope"); - expect(html).toContain("<b>nope</b>"); - expect(html).not.toContain("nope"); - }); - - it("flattens images into alt text", () => { - const html = markdownToMatrixHtml("![alt](https://example.com/img.png)"); - expect(html).toContain("alt"); - expect(html).not.toContain(" { - const html = markdownToMatrixHtml("line1\nline2"); - expect(html).toContain(" escapeHtml(tokens[idx]?.content ?? ""); - -md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); -md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); - -export function markdownToMatrixHtml(markdown: string): string { - const rendered = md.render(markdown ?? ""); - return rendered.trimEnd(); -} diff --git a/extensions/matrix-js/src/matrix/index.ts b/extensions/matrix-js/src/matrix/index.ts deleted file mode 100644 index 7cd75d8a1ae..00000000000 --- a/extensions/matrix-js/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-js/src/matrix/monitor/allowlist.test.ts b/extensions/matrix-js/src/matrix/monitor/allowlist.test.ts deleted file mode 100644 index d91ef71ceeb..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/allowlist.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js"; - -describe("resolveMatrixAllowListMatch", () => { - it("matches full user IDs and prefixes", () => { - const userId = "@Alice:Example.org"; - const direct = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(["@alice:example.org"]), - userId, - }); - expect(direct.allowed).toBe(true); - expect(direct.matchSource).toBe("id"); - - const prefixedMatrix = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(["matrix:@alice:example.org"]), - userId, - }); - expect(prefixedMatrix.allowed).toBe(true); - expect(prefixedMatrix.matchSource).toBe("prefixed-id"); - - const prefixedUser = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(["user:@alice:example.org"]), - userId, - }); - expect(prefixedUser.allowed).toBe(true); - expect(prefixedUser.matchSource).toBe("prefixed-user"); - }); - - it("ignores display names and localparts", () => { - const match = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(["alice", "Alice"]), - userId: "@alice:example.org", - }); - expect(match.allowed).toBe(false); - }); - - it("matches wildcard", () => { - const match = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(["*"]), - userId: "@alice:example.org", - }); - expect(match.allowed).toBe(true); - expect(match.matchSource).toBe("wildcard"); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/allowlist.ts b/extensions/matrix-js/src/matrix/monitor/allowlist.ts deleted file mode 100644 index eabb263baca..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/allowlist.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { AllowlistMatch } from "openclaw/plugin-sdk/matrix-js"; - -function normalizeAllowList(list?: Array) { - return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); -} - -function normalizeMatrixUser(raw?: string | null): string { - const value = (raw ?? "").trim(); - if (!value) { - return ""; - } - if (!value.startsWith("@") || !value.includes(":")) { - return value.toLowerCase(); - } - const withoutAt = value.slice(1); - const splitIndex = withoutAt.indexOf(":"); - if (splitIndex === -1) { - return value.toLowerCase(); - } - const localpart = withoutAt.slice(0, splitIndex).toLowerCase(); - const server = withoutAt.slice(splitIndex + 1).toLowerCase(); - if (!server) { - return value.toLowerCase(); - } - return `@${localpart}:${server.toLowerCase()}`; -} - -export function normalizeMatrixUserId(raw?: string | null): string { - const trimmed = (raw ?? "").trim(); - if (!trimmed) { - return ""; - } - const lowered = trimmed.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return normalizeMatrixUser(trimmed.slice("matrix:".length)); - } - if (lowered.startsWith("user:")) { - return normalizeMatrixUser(trimmed.slice("user:".length)); - } - return normalizeMatrixUser(trimmed); -} - -function normalizeMatrixAllowListEntry(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - if (trimmed === "*") { - return trimmed; - } - const lowered = trimmed.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return `matrix:${normalizeMatrixUser(trimmed.slice("matrix:".length))}`; - } - if (lowered.startsWith("user:")) { - return `user:${normalizeMatrixUser(trimmed.slice("user:".length))}`; - } - return normalizeMatrixUser(trimmed); -} - -export function normalizeMatrixAllowList(list?: Array) { - return normalizeAllowList(list).map((entry) => normalizeMatrixAllowListEntry(entry)); -} - -export type MatrixAllowListMatch = AllowlistMatch< - "wildcard" | "id" | "prefixed-id" | "prefixed-user" ->; - -export function resolveMatrixAllowListMatch(params: { - allowList: string[]; - userId?: string; -}): MatrixAllowListMatch { - const allowList = params.allowList; - if (allowList.length === 0) { - return { allowed: false }; - } - if (allowList.includes("*")) { - return { allowed: true, matchKey: "*", matchSource: "wildcard" }; - } - const userId = normalizeMatrixUser(params.userId); - const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [ - { value: userId, source: "id" }, - { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" }, - { value: userId ? `user:${userId}` : "", source: "prefixed-user" }, - ]; - for (const candidate of candidates) { - if (!candidate.value) { - continue; - } - if (allowList.includes(candidate.value)) { - return { - allowed: true, - matchKey: candidate.value, - matchSource: candidate.source, - }; - } - } - return { allowed: false }; -} - -export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) { - return resolveMatrixAllowListMatch(params).allowed; -} diff --git a/extensions/matrix-js/src/matrix/monitor/auto-join.ts b/extensions/matrix-js/src/matrix/monitor/auto-join.ts deleted file mode 100644 index 6174d08d51f..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/auto-join.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix-js"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig } from "../../types.js"; -import type { MatrixClient } from "../sdk.js"; - -export function registerMatrixAutoJoin(params: { - client: MatrixClient; - cfg: CoreConfig; - runtime: RuntimeEnv; -}) { - const { client, cfg, runtime } = params; - const core = getMatrixRuntime(); - const logVerbose = (message: string) => { - if (!core.logging.shouldLogVerbose()) { - return; - } - runtime.log?.(message); - }; - const autoJoin = cfg.channels?.["matrix-js"]?.autoJoin ?? "always"; - const autoJoinAllowlist = new Set( - (cfg.channels?.["matrix-js"]?.autoJoinAllowlist ?? []) - .map((entry) => String(entry).trim()) - .filter(Boolean), - ); - - if (autoJoin === "off") { - return; - } - - if (autoJoin === "always") { - logVerbose("matrix: auto-join enabled for all invites"); - } else { - logVerbose("matrix: auto-join enabled for allowlist invites"); - } - - // Handle invites directly so both "always" and "allowlist" modes share the same path. - client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { - if (autoJoin === "allowlist") { - let alias: string | undefined; - let altAliases: string[] = []; - try { - const aliasState = await client - .getRoomStateEvent(roomId, "m.room.canonical_alias", "") - .catch(() => null); - alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined; - altAliases = - aliasState && Array.isArray(aliasState.alt_aliases) - ? aliasState.alt_aliases - .map((entry) => (typeof entry === "string" ? entry.trim() : "")) - .filter(Boolean) - : []; - } catch { - // Ignore errors - } - - const allowed = - autoJoinAllowlist.has("*") || - autoJoinAllowlist.has(roomId) || - (alias ? autoJoinAllowlist.has(alias) : false) || - altAliases.some((value) => autoJoinAllowlist.has(value)); - - if (!allowed) { - logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); - return; - } - } - - try { - await client.joinRoom(roomId); - logVerbose(`matrix: joined room ${roomId}`); - } catch (err) { - runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`); - } - }); -} diff --git a/extensions/matrix-js/src/matrix/monitor/direct.ts b/extensions/matrix-js/src/matrix/monitor/direct.ts deleted file mode 100644 index de767e1db08..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/direct.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { MatrixClient } from "../sdk.js"; - -type DirectMessageCheck = { - roomId: string; - senderId?: string; - selfUserId?: string; -}; - -type DirectRoomTrackerOptions = { - log?: (message: string) => void; -}; - -const DM_CACHE_TTL_MS = 30_000; - -export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) { - const log = opts.log ?? (() => {}); - let lastDmUpdateMs = 0; - let cachedSelfUserId: string | null = null; - const memberCountCache = new Map(); - - const ensureSelfUserId = async (): Promise => { - if (cachedSelfUserId) { - return cachedSelfUserId; - } - try { - cachedSelfUserId = await client.getUserId(); - } catch { - cachedSelfUserId = null; - } - return cachedSelfUserId; - }; - - const refreshDmCache = async (): Promise => { - const now = Date.now(); - if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) { - return; - } - lastDmUpdateMs = now; - try { - await client.dms.update(); - } catch (err) { - log(`matrix: dm cache refresh failed (${String(err)})`); - } - }; - - const resolveMemberCount = async (roomId: string): Promise => { - const cached = memberCountCache.get(roomId); - const now = Date.now(); - if (cached && now - cached.ts < DM_CACHE_TTL_MS) { - return cached.count; - } - try { - const members = await client.getJoinedRoomMembers(roomId); - const count = members.length; - memberCountCache.set(roomId, { count, ts: now }); - return count; - } catch (err) { - log(`matrix: dm member count failed room=${roomId} (${String(err)})`); - return null; - } - }; - - const hasDirectFlag = async (roomId: string, userId?: string): Promise => { - const target = userId?.trim(); - if (!target) { - return false; - } - try { - const state = await client.getRoomStateEvent(roomId, "m.room.member", target); - return state?.is_direct === true; - } catch { - return false; - } - }; - - return { - isDirectMessage: async (params: DirectMessageCheck): Promise => { - const { roomId, senderId } = params; - await refreshDmCache(); - - if (client.dms.isDm(roomId)) { - log(`matrix: dm detected via m.direct room=${roomId}`); - return true; - } - - const memberCount = await resolveMemberCount(roomId); - if (memberCount === 2) { - log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`); - return true; - } - - const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); - const directViaState = - (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? "")); - if (directViaState) { - log(`matrix: dm detected via member state room=${roomId}`); - return true; - } - - log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`); - return false; - }, - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/events.test.ts b/extensions/matrix-js/src/matrix/monitor/events.test.ts deleted file mode 100644 index bda9748a32c..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/events.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MatrixAuth } from "../client.js"; -import type { MatrixClient } from "../sdk.js"; -import { registerMatrixMonitorEvents } from "./events.js"; -import type { MatrixRawEvent } from "./types.js"; -import { EventType } from "./types.js"; - -type RoomEventListener = (roomId: string, event: MatrixRawEvent) => void; - -function getSentNoticeBody(sendMessage: ReturnType, index = 0): string { - const calls = sendMessage.mock.calls as unknown[][]; - const payload = (calls[index]?.[1] ?? {}) as { body?: string }; - return payload.body ?? ""; -} - -function createHarness(params?: { - verifications?: Array<{ - id: string; - transactionId?: string; - roomId?: string; - otherUserId: string; - phaseName: string; - updatedAt?: string; - completed?: boolean; - sas?: { - decimal?: [number, number, number]; - emoji?: Array<[string, string]>; - }; - }>; -}) { - const listeners = new Map void>(); - const onRoomMessage = vi.fn(async () => {}); - const listVerifications = vi.fn(async () => params?.verifications ?? []); - const sendMessage = vi.fn(async () => "$notice"); - const client = { - on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { - listeners.set(eventName, listener); - return client; - }), - sendMessage, - crypto: { - listVerifications, - }, - } as unknown as MatrixClient; - - registerMatrixMonitorEvents({ - client, - auth: { encryption: true } as MatrixAuth, - logVerboseMessage: vi.fn(), - warnedEncryptedRooms: new Set(), - warnedCryptoMissingRooms: new Set(), - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, - formatNativeDependencyHint: vi.fn(() => "install hint"), - onRoomMessage, - }); - - const roomEventListener = listeners.get("room.event") as RoomEventListener | undefined; - if (!roomEventListener) { - throw new Error("room.event listener was not registered"); - } - - return { - onRoomMessage, - sendMessage, - roomEventListener, - listVerifications, - roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, - }; -} - -describe("registerMatrixMonitorEvents verification routing", () => { - it("forwards reaction room events into the shared room handler", async () => { - const { onRoomMessage, sendMessage, roomEventListener } = createHarness(); - - roomEventListener("!room:example.org", { - event_id: "$reaction1", - sender: "@alice:example.org", - type: EventType.Reaction, - origin_server_ts: Date.now(), - content: { - "m.relates_to": { - rel_type: "m.annotation", - event_id: "$msg1", - key: "👍", - }, - }, - }); - - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith( - "!room:example.org", - expect.objectContaining({ event_id: "$reaction1", type: EventType.Reaction }), - ); - }); - expect(sendMessage).not.toHaveBeenCalled(); - }); - - it("posts verification request notices directly into the room", async () => { - const { onRoomMessage, sendMessage, roomMessageListener } = createHarness(); - if (!roomMessageListener) { - throw new Error("room.message listener was not registered"); - } - roomMessageListener("!room:example.org", { - event_id: "$req1", - sender: "@alice:example.org", - type: EventType.RoomMessage, - origin_server_ts: Date.now(), - content: { - msgtype: "m.key.verification.request", - body: "verification request", - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - expect(onRoomMessage).not.toHaveBeenCalled(); - const body = getSentNoticeBody(sendMessage, 0); - expect(body).toContain("Matrix verification request received from @alice:example.org."); - expect(body).toContain('Open "Verify by emoji"'); - }); - - it("posts ready-stage guidance for emoji verification", async () => { - const { sendMessage, roomEventListener } = createHarness(); - roomEventListener("!room:example.org", { - event_id: "$ready-1", - sender: "@alice:example.org", - type: "m.key.verification.ready", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { event_id: "$req-ready-1" }, - }, - }); - - await vi.waitFor(() => { - expect(sendMessage).toHaveBeenCalledTimes(1); - }); - const body = getSentNoticeBody(sendMessage, 0); - expect(body).toContain("Matrix verification is ready with @alice:example.org."); - expect(body).toContain('Choose "Verify by emoji"'); - }); - - it("posts SAS emoji/decimal details when verification summaries expose them", async () => { - const { sendMessage, roomEventListener, listVerifications } = createHarness({ - verifications: [ - { - id: "verification-1", - transactionId: "$different-flow-id", - updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), - otherUserId: "@alice:example.org", - phaseName: "started", - sas: { - decimal: [6158, 1986, 3513], - emoji: [ - ["🎁", "Gift"], - ["🌍", "Globe"], - ["🐴", "Horse"], - ], - }, - }, - ], - }); - - roomEventListener("!room:example.org", { - event_id: "$start2", - sender: "@alice:example.org", - type: "m.key.verification.start", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { event_id: "$req2" }, - }, - }); - - await vi.waitFor(() => { - const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => - String((call[1] as { body?: string } | undefined)?.body ?? ""), - ); - expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); - expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); - }); - }); - - it("does not emit duplicate SAS notices for the same verification payload", async () => { - const { sendMessage, roomEventListener, listVerifications } = createHarness({ - verifications: [ - { - id: "verification-3", - transactionId: "$req3", - otherUserId: "@alice:example.org", - phaseName: "started", - sas: { - decimal: [1111, 2222, 3333], - emoji: [ - ["🚀", "Rocket"], - ["🦋", "Butterfly"], - ["📕", "Book"], - ], - }, - }, - ], - }); - - roomEventListener("!room:example.org", { - event_id: "$start3", - sender: "@alice:example.org", - type: "m.key.verification.start", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { event_id: "$req3" }, - }, - }); - await vi.waitFor(() => { - expect(sendMessage.mock.calls.length).toBeGreaterThan(0); - }); - - roomEventListener("!room:example.org", { - event_id: "$key3", - sender: "@alice:example.org", - type: "m.key.verification.key", - origin_server_ts: Date.now(), - content: { - "m.relates_to": { event_id: "$req3" }, - }, - }); - await vi.waitFor(() => { - expect(listVerifications).toHaveBeenCalledTimes(2); - }); - - const sasBodies = sendMessage.mock.calls - .map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? "")) - .filter((body) => body.includes("SAS emoji:")); - expect(sasBodies).toHaveLength(1); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/events.ts b/extensions/matrix-js/src/matrix/monitor/events.ts deleted file mode 100644 index 93e4b431861..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/events.ts +++ /dev/null @@ -1,400 +0,0 @@ -import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix-js"; -import type { MatrixAuth } from "../client.js"; -import type { MatrixClient } from "../sdk.js"; -import type { MatrixRawEvent } from "./types.js"; -import { EventType } from "./types.js"; -import { - isMatrixVerificationEventType, - isMatrixVerificationRequestMsgType, - matrixVerificationConstants, -} from "./verification-utils.js"; - -const MAX_TRACKED_VERIFICATION_EVENTS = 1024; - -type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; - -type MatrixVerificationSummaryLike = { - id: string; - transactionId?: string; - roomId?: string; - otherUserId: string; - phaseName: string; - updatedAt?: string; - completed?: boolean; - sas?: { - decimal?: [number, number, number]; - emoji?: Array<[string, string]>; - }; -}; - -function trimMaybeString(input: unknown): string | null { - if (typeof input !== "string") { - return null; - } - const trimmed = input.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function readVerificationSignal(event: MatrixRawEvent): { - stage: MatrixVerificationStage; - flowId: string | null; -} | null { - const type = trimMaybeString(event?.type) ?? ""; - const content = event?.content ?? {}; - const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; - const relatedEventId = trimMaybeString( - (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, - ); - const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); - if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { - return { - stage: "request", - flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, - }; - } - if (!isMatrixVerificationEventType(type)) { - return null; - } - const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); - if (type === `${matrixVerificationConstants.eventPrefix}request`) { - return { stage: "request", flowId }; - } - if (type === `${matrixVerificationConstants.eventPrefix}ready`) { - return { stage: "ready", flowId }; - } - if (type === "m.key.verification.start") { - return { stage: "start", flowId }; - } - if (type === "m.key.verification.cancel") { - return { stage: "cancel", flowId }; - } - if (type === "m.key.verification.done") { - return { stage: "done", flowId }; - } - return { stage: "other", flowId }; -} - -function formatVerificationStageNotice(params: { - stage: MatrixVerificationStage; - senderId: string; - event: MatrixRawEvent; -}): string | null { - const { stage, senderId, event } = params; - const content = event.content as { code?: unknown; reason?: unknown }; - switch (stage) { - case "request": - return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; - case "ready": - return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; - case "start": - return `Matrix verification started with ${senderId}.`; - case "done": - return `Matrix verification completed with ${senderId}.`; - case "cancel": { - const code = trimMaybeString(content.code); - const reason = trimMaybeString(content.reason); - if (code && reason) { - return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`; - } - if (reason) { - return `Matrix verification cancelled by ${senderId} (${reason}).`; - } - return `Matrix verification cancelled by ${senderId}.`; - } - default: - return null; - } -} - -function formatVerificationSasNotice(summary: MatrixVerificationSummaryLike): string | null { - const sas = summary.sas; - if (!sas) { - return null; - } - const emojiLine = - Array.isArray(sas.emoji) && sas.emoji.length > 0 - ? `SAS emoji: ${sas.emoji - .map( - ([emoji, name]) => `${trimMaybeString(emoji) ?? "?"} ${trimMaybeString(name) ?? "?"}`, - ) - .join(" | ")}` - : null; - const decimalLine = - Array.isArray(sas.decimal) && sas.decimal.length === 3 - ? `SAS decimal: ${sas.decimal.join(" ")}` - : null; - if (!emojiLine && !decimalLine) { - return null; - } - const lines = [`Matrix verification SAS with ${summary.otherUserId}:`]; - if (emojiLine) { - lines.push(emojiLine); - } - if (decimalLine) { - lines.push(decimalLine); - } - lines.push("If both sides match, choose 'They match' in your Matrix app."); - return lines.join("\n"); -} - -function resolveVerificationFlowCandidates(params: { - event: MatrixRawEvent; - flowId: string | null; -}): string[] { - const { event, flowId } = params; - const content = event.content as { - transaction_id?: unknown; - "m.relates_to"?: { event_id?: unknown }; - }; - const candidates = new Set(); - const add = (value: unknown) => { - const normalized = trimMaybeString(value); - if (normalized) { - candidates.add(normalized); - } - }; - add(flowId); - add(event.event_id); - add(content.transaction_id); - add(content["m.relates_to"]?.event_id); - return Array.from(candidates); -} - -function resolveSummaryRecency(summary: MatrixVerificationSummaryLike): number { - const ts = Date.parse(summary.updatedAt ?? ""); - return Number.isFinite(ts) ? ts : 0; -} - -async function resolveVerificationSummaryForSignal( - client: MatrixClient, - params: { - event: MatrixRawEvent; - senderId: string; - flowId: string | null; - }, -): Promise { - if (!client.crypto) { - return null; - } - const list = await client.crypto.listVerifications(); - if (list.length === 0) { - return null; - } - const candidates = resolveVerificationFlowCandidates({ - event: params.event, - flowId: params.flowId, - }); - const byTransactionId = list.find((entry) => - candidates.some((candidate) => entry.transactionId === candidate), - ); - if (byTransactionId) { - return byTransactionId; - } - - // Fallback for flows where transaction IDs do not match room event IDs consistently. - const byUser = list - .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) - .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; - return byUser ?? null; -} - -function trackBounded(set: Set, 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 registerMatrixMonitorEvents(params: { - client: MatrixClient; - auth: MatrixAuth; - logVerboseMessage: (message: string) => void; - warnedEncryptedRooms: Set; - warnedCryptoMissingRooms: Set; - logger: RuntimeLogger; - formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; - onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; -}): void { - const { - client, - auth, - logVerboseMessage, - warnedEncryptedRooms, - warnedCryptoMissingRooms, - logger, - formatNativeDependencyHint, - onRoomMessage, - } = params; - const routedVerificationEvents = new Set(); - const routedVerificationSasFingerprints = new Set(); - - const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => { - const senderId = trimMaybeString(event?.sender); - if (!senderId) { - return false; - } - const signal = readVerificationSignal(event); - if (!signal) { - return false; - } - - void (async () => { - const flowId = signal.flowId; - const sourceEventId = trimMaybeString(event?.event_id); - const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; - if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { - return; - } - - const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); - const summary = await resolveVerificationSummaryForSignal(client, { - event, - senderId, - flowId, - }).catch(() => null); - const sasNotice = summary ? formatVerificationSasNotice(summary) : null; - - const notices: string[] = []; - if (stageNotice) { - notices.push(stageNotice); - } - if (summary && sasNotice) { - const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; - if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { - notices.push(sasNotice); - } - } - if (notices.length === 0) { - return; - } - - for (const body of notices) { - await sendVerificationNotice({ - client, - roomId, - body, - logVerboseMessage, - }); - } - })().catch((err) => { - logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); - }); - - return true; - }; - - client.on("room.message", (roomId: string, event: MatrixRawEvent) => { - if (routeVerificationEvent(roomId, event)) { - return; - } - void onRoomMessage(roomId, event); - }); - - client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { - const eventId = event?.event_id ?? "unknown"; - const eventType = event?.type ?? "unknown"; - logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`); - }); - - client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => { - const eventId = event?.event_id ?? "unknown"; - const eventType = event?.type ?? "unknown"; - logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`); - }); - - client.on( - "room.failed_decryption", - async (roomId: string, event: MatrixRawEvent, error: Error) => { - logger.warn("Failed to decrypt message", { - roomId, - eventId: event.event_id, - error: error.message, - }); - logVerboseMessage( - `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, - ); - }, - ); - - client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { - const eventId = event?.event_id ?? "unknown"; - const sender = event?.sender ?? "unknown"; - const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; - logVerboseMessage( - `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, - ); - }); - - client.on("room.join", (roomId: string, event: MatrixRawEvent) => { - const eventId = event?.event_id ?? "unknown"; - logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`); - }); - - client.on("room.event", (roomId: string, event: MatrixRawEvent) => { - const eventType = event?.type ?? "unknown"; - if (eventType === EventType.RoomMessageEncrypted) { - logVerboseMessage( - `matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`, - ); - if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { - warnedEncryptedRooms.add(roomId); - const warning = - "matrix: encrypted event received without encryption enabled; set channels.matrix-js.encryption=true and verify the device to decrypt"; - logger.warn(warning, { roomId }); - } - if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { - warnedCryptoMissingRooms.add(roomId); - const hint = formatNativeDependencyHint({ - packageName: "@matrix-org/matrix-sdk-crypto-nodejs", - manager: "pnpm", - downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", - }); - const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; - logger.warn(warning, { roomId }); - } - return; - } - if (eventType === EventType.RoomMember) { - const membership = (event?.content as { membership?: string } | undefined)?.membership; - const stateKey = (event as { state_key?: string }).state_key ?? ""; - logVerboseMessage( - `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, - ); - } - if (eventType === EventType.Reaction) { - void onRoomMessage(roomId, event); - return; - } - - routeVerificationEvent(roomId, event); - }); -} diff --git a/extensions/matrix-js/src/matrix/monitor/handler.ts b/extensions/matrix-js/src/matrix/monitor/handler.ts deleted file mode 100644 index 57aa35f089d..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/handler.ts +++ /dev/null @@ -1,805 +0,0 @@ -import { - createReplyPrefixOptions, - createTypingCallbacks, - ensureConfiguredAcpRouteReady, - formatAllowlistMatchMeta, - getSessionBindingService, - logInboundDrop, - logTypingFailure, - resolveAgentIdFromSessionKey, - resolveConfiguredAcpRoute, - resolveControlCommandGate, - type PluginRuntime, - type ReplyPayload, - type RuntimeEnv, - type RuntimeLogger, -} from "openclaw/plugin-sdk/matrix-js"; -import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; -import { - formatPollAsText, - isPollStartType, - parsePollStartContent, - type PollStartContent, -} from "../poll-types.js"; -import type { LocationMessageEventContent, MatrixClient } from "../sdk.js"; -import { - reactMatrixMessage, - sendMessageMatrix, - sendReadReceiptMatrix, - sendTypingMatrix, -} from "../send.js"; -import { resolveMatrixAckReactionConfig } from "./ack-config.js"; -import { - normalizeMatrixAllowList, - resolveMatrixAllowListMatch, - resolveMatrixAllowListMatches, -} from "./allowlist.js"; -import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; -import { downloadMatrixMedia } from "./media.js"; -import { resolveMentions } from "./mentions.js"; -import { handleInboundMatrixReaction } from "./reaction-events.js"; -import { deliverMatrixReplies } from "./replies.js"; -import { resolveMatrixRoomConfig } from "./rooms.js"; -import { createMatrixThreadContextResolver } from "./thread-context.js"; -import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; -import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; -import { EventType, RelationType } from "./types.js"; -import { isMatrixVerificationRoomMessage } from "./verification-utils.js"; - -const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000; -const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000; -const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512; - -export type MatrixMonitorHandlerParams = { - client: MatrixClient; - core: PluginRuntime; - cfg: CoreConfig; - accountId: string; - runtime: RuntimeEnv; - logger: RuntimeLogger; - logVerboseMessage: (message: string) => void; - allowFrom: string[]; - roomsConfig?: Record; - mentionRegexes: ReturnType; - groupPolicy: "open" | "allowlist" | "disabled"; - replyToMode: ReplyToMode; - threadReplies: "off" | "inbound" | "always"; - dmEnabled: boolean; - dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; - textLimit: number; - mediaMaxBytes: number; - startupMs: number; - startupGraceMs: number; - directTracker: { - isDirectMessage: (params: { - roomId: string; - senderId: string; - selfUserId: string; - }) => Promise; - }; - getRoomInfo: ( - roomId: string, - ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; - getMemberDisplayName: (roomId: string, userId: string) => Promise; -}; - -export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { - const { - client, - core, - cfg, - accountId, - runtime, - logger, - logVerboseMessage, - allowFrom, - roomsConfig, - mentionRegexes, - groupPolicy, - replyToMode, - threadReplies, - dmEnabled, - dmPolicy, - textLimit, - mediaMaxBytes, - startupMs, - startupGraceMs, - directTracker, - getRoomInfo, - getMemberDisplayName, - } = params; - let cachedStoreAllowFrom: { - value: string[]; - expiresAtMs: number; - } | null = null; - const pairingReplySentAtMsBySender = new Map(); - 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-js", - env: process.env, - accountId, - }) - .catch(() => []); - cachedStoreAllowFrom = { - value, - expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS, - }; - return value; - }; - - const shouldSendPairingReply = (senderId: string, created: boolean): boolean => { - const now = Date.now(); - if (created) { - pairingReplySentAtMsBySender.set(senderId, now); - return true; - } - const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId); - if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) { - return false; - } - pairingReplySentAtMsBySender.set(senderId, now); - if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) { - const oldestSender = pairingReplySentAtMsBySender.keys().next().value; - if (typeof oldestSender === "string") { - pairingReplySentAtMsBySender.delete(oldestSender); - } - } - return true; - }; - - return async (roomId: string, event: MatrixRawEvent) => { - try { - const eventType = event.type; - if (eventType === EventType.RoomMessageEncrypted) { - // Encrypted payloads are emitted separately after decryption. - return; - } - - const isPollEvent = isPollStartType(eventType); - const isReactionEvent = eventType === EventType.Reaction; - const locationContent = event.content as LocationMessageEventContent; - const isLocationEvent = - eventType === EventType.Location || - (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); - if ( - eventType !== EventType.RoomMessage && - !isPollEvent && - !isLocationEvent && - !isReactionEvent - ) { - return; - } - logVerboseMessage( - `matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, - ); - if (event.unsigned?.redacted_because) { - return; - } - const senderId = event.sender; - if (!senderId) { - return; - } - const selfUserId = await client.getUserId(); - if (senderId === selfUserId) { - return; - } - const eventTs = event.origin_server_ts; - const eventAge = event.unsigned?.age; - if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { - return; - } - if ( - typeof eventTs !== "number" && - typeof eventAge === "number" && - eventAge > startupGraceMs - ) { - return; - } - - const roomInfo = await getRoomInfo(roomId); - const roomName = roomInfo.name; - const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean); - - let content = event.content as RoomMessageEventContent; - if (isPollEvent) { - const pollStartContent = event.content as PollStartContent; - const pollSummary = parsePollStartContent(pollStartContent); - if (pollSummary) { - pollSummary.eventId = event.event_id ?? ""; - pollSummary.roomId = roomId; - pollSummary.sender = senderId; - const senderDisplayName = await getMemberDisplayName(roomId, senderId); - pollSummary.senderName = senderDisplayName; - const pollText = formatPollAsText(pollSummary); - content = { - msgtype: "m.text", - body: pollText, - } as unknown as RoomMessageEventContent; - } else { - return; - } - } - - if ( - eventType === EventType.RoomMessage && - isMatrixVerificationRoomMessage({ - msgtype: (content as { msgtype?: unknown }).msgtype, - body: content.body, - }) - ) { - logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`); - return; - } - - const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ - eventType, - content: content as LocationMessageEventContent, - }); - - const relates = content["m.relates_to"]; - if (relates && "rel_type" in relates) { - if (relates.rel_type === RelationType.Replace) { - return; - } - } - - const isDirectMessage = await directTracker.isDirectMessage({ - roomId, - senderId, - selfUserId, - }); - const isRoom = !isDirectMessage; - - if (isRoom && groupPolicy === "disabled") { - return; - } - - const roomConfigInfo = isRoom - ? resolveMatrixRoomConfig({ - rooms: roomsConfig, - roomId, - aliases: roomAliases, - name: roomName, - }) - : undefined; - const roomConfig = roomConfigInfo?.config; - const roomMatchMeta = roomConfigInfo - ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ - roomConfigInfo.matchSource ?? "none" - }` - : "matchKey=none matchSource=none"; - - if (isRoom && roomConfig && !roomConfigInfo?.allowed) { - logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); - return; - } - if (isRoom && groupPolicy === "allowlist") { - if (!roomConfigInfo?.allowlistConfigured) { - logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); - return; - } - if (!roomConfig) { - logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); - return; - } - } - - const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await readStoreAllowFrom(); - const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); - const groupAllowFrom = cfg.channels?.["matrix-js"]?.groupAllowFrom ?? []; - const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); - const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; - - if (isDirectMessage) { - if (!dmEnabled || dmPolicy === "disabled") { - return; - } - if (dmPolicy !== "open") { - const allowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveAllowFrom, - userId: senderId, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (!isReactionEvent && dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "matrix-js", - id: senderId, - accountId, - meta: { name: senderName }, - }); - if (shouldSendPairingReply(senderId, created)) { - const pairingReply = core.channel.pairing.buildPairingReply({ - channel: "matrix-js", - idLine: `Your Matrix user id: ${senderId}`, - code, - }); - logVerboseMessage( - created - ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})` - : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, - ); - try { - await sendMessageMatrix( - `room:${roomId}`, - created - ? pairingReply - : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`, - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } - } else { - logVerboseMessage( - `matrix pairing reminder suppressed sender=${senderId} (cooldown)`, - ); - } - } - if (isReactionEvent || dmPolicy !== "pairing") { - logVerboseMessage( - `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return; - } - } - } - - const roomUsers = roomConfig?.users ?? []; - if (isRoom && roomUsers.length > 0) { - const userMatch = resolveMatrixAllowListMatch({ - allowList: normalizeMatrixAllowList(roomUsers), - userId: senderId, - }); - if (!userMatch.allowed) { - logVerboseMessage( - `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - userMatch, - )})`, - ); - return; - } - } - if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { - const groupAllowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveGroupAllowFrom, - userId: senderId, - }); - if (!groupAllowMatch.allowed) { - logVerboseMessage( - `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - groupAllowMatch, - )})`, - ); - return; - } - } - if (isRoom) { - logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); - } - - if (isReactionEvent) { - await handleInboundMatrixReaction({ - client, - core, - cfg, - accountId, - roomId, - event, - senderId, - senderLabel: senderName, - selfUserId, - isDirectMessage, - logVerboseMessage, - }); - return; - } - - const rawBody = - locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); - let media: { - path: string; - contentType?: string; - placeholder: string; - } | null = null; - const contentUrl = - "url" in content && typeof content.url === "string" ? content.url : undefined; - const contentFile = - "file" in content && content.file && typeof content.file === "object" - ? content.file - : undefined; - const mediaUrl = contentUrl ?? contentFile?.url; - if (!rawBody && !mediaUrl) { - return; - } - - const contentInfo = - "info" in content && content.info && typeof content.info === "object" - ? (content.info as { mimetype?: string; size?: number }) - : undefined; - const contentType = contentInfo?.mimetype; - const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined; - if (mediaUrl?.startsWith("mxc://")) { - try { - media = await downloadMatrixMedia({ - client, - mxcUrl: mediaUrl, - contentType, - sizeBytes: contentSize, - maxBytes: mediaMaxBytes, - file: contentFile, - }); - } catch (err) { - logVerboseMessage(`matrix: media download failed: ${String(err)}`); - } - } - - const bodyText = rawBody || media?.placeholder || ""; - if (!bodyText) { - return; - } - - const { wasMentioned, hasExplicitMention } = resolveMentions({ - content, - userId: selfUserId, - text: bodyText, - mentionRegexes, - }); - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ - cfg, - surface: "matrix-js", - }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = resolveMatrixAllowListMatches({ - allowList: effectiveAllowFrom, - userId: senderId, - }); - const senderAllowedForGroup = groupAllowConfigured - ? resolveMatrixAllowListMatches({ - allowList: effectiveGroupAllowFrom, - userId: senderId, - }) - : false; - const senderAllowedForRoomUsers = - isRoom && roomUsers.length > 0 - ? resolveMatrixAllowListMatches({ - allowList: normalizeMatrixAllowList(roomUsers), - userId: senderId, - }) - : false; - const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); - const commandGate = resolveControlCommandGate({ - useAccessGroups, - authorizers: [ - { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, - { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, - { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, - ], - allowTextCommands, - hasControlCommand: hasControlCommandInMessage, - }); - const commandAuthorized = commandGate.commandAuthorized; - if (isRoom && commandGate.shouldBlock) { - logInboundDrop({ - log: logVerboseMessage, - channel: "matrix-js", - reason: "control command (unauthorized)", - target: senderId, - }); - return; - } - const shouldRequireMention = isRoom - ? roomConfig?.autoReply === true - ? false - : roomConfig?.autoReply === false - ? true - : typeof roomConfig?.requireMention === "boolean" - ? roomConfig?.requireMention - : true - : false; - const shouldBypassMention = - allowTextCommands && - isRoom && - shouldRequireMention && - !wasMentioned && - !hasExplicitMention && - commandAuthorized && - hasControlCommandInMessage; - const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; - if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { - logger.info("skipping room message", { roomId, reason: "no-mention" }); - return; - } - - const messageId = event.event_id ?? ""; - const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; - const threadRootId = resolveMatrixThreadRootId({ event, content }); - const threadTarget = resolveMatrixThreadTarget({ - threadReplies, - messageId, - threadRootId, - isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata. - }); - const threadContext = threadRootId - ? await resolveThreadContext({ roomId, threadRootId }) - : undefined; - - const baseRoute = core.channel.routing.resolveAgentRoute({ - cfg, - channel: "matrix-js", - accountId, - peer: { - kind: isDirectMessage ? "direct" : "channel", - id: isDirectMessage ? senderId : roomId, - }, - }); - const bindingConversationId = - threadRootId && threadRootId !== messageId ? threadRootId : roomId; - const bindingParentConversationId = bindingConversationId === roomId ? undefined : roomId; - const sessionBindingService = getSessionBindingService(); - const runtimeBinding = sessionBindingService.resolveByConversation({ - channel: "matrix-js", - accountId, - conversationId: bindingConversationId, - parentConversationId: bindingParentConversationId, - }); - const configuredRoute = - runtimeBinding == null - ? resolveConfiguredAcpRoute({ - cfg, - route: baseRoute, - channel: "matrix-js", - accountId, - conversationId: bindingConversationId, - parentConversationId: bindingParentConversationId, - }) - : null; - const configuredBinding = configuredRoute?.configuredBinding ?? null; - if (!runtimeBinding && configuredBinding) { - const ensured = await ensureConfiguredAcpRouteReady({ - cfg, - configuredBinding, - }); - if (!ensured.ok) { - logInboundDrop({ - log: logVerboseMessage, - channel: "matrix-js", - reason: "configured ACP binding unavailable", - target: configuredBinding.spec.conversationId, - }); - return; - } - } - const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); - const route = - runtimeBinding && boundSessionKey - ? { - ...baseRoute, - sessionKey: boundSessionKey, - agentId: resolveAgentIdFromSessionKey(boundSessionKey) || baseRoute.agentId, - matchedBy: "binding.channel" as const, - } - : (configuredRoute?.route ?? baseRoute); - if (runtimeBinding) { - sessionBindingService.touch(runtimeBinding.bindingId, eventTs); - } - const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ - channel: "Matrix", - from: envelopeFrom, - timestamp: eventTs ?? undefined, - previousTimestamp, - envelope: envelopeOptions, - body: textWithId, - }); - - const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - RawBody: bodyText, - CommandBody: bodyText, - From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, - To: `room:${roomId}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "channel", - ConversationLabel: envelopeFrom, - SenderName: senderName, - SenderId: senderId, - SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), - GroupSubject: isRoom ? (roomName ?? roomId) : undefined, - GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, - GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, - Provider: "matrix-js" as const, - Surface: "matrix-js" as const, - WasMentioned: isRoom ? wasMentioned : undefined, - MessageSid: messageId, - ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), - MessageThreadId: threadTarget, - ThreadStarterBody: threadContext?.threadStarterBody, - Timestamp: eventTs ?? undefined, - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, - ...locationPayload?.context, - CommandAuthorized: commandAuthorized, - CommandSource: "text" as const, - OriginatingChannel: "matrix-js" as const, - OriginatingTo: `room:${roomId}`, - }); - - await core.channel.session.recordInboundSession({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - updateLastRoute: isDirectMessage - ? { - sessionKey: route.mainSessionKey, - channel: "matrix-js", - to: `room:${roomId}`, - accountId: route.accountId, - } - : undefined, - onRecordError: (err) => { - logger.warn("failed updating session meta", { - error: String(err), - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - }); - }, - }); - - const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); - logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); - - const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({ - cfg, - agentId: route.agentId, - accountId, - }); - const shouldAckReaction = () => - Boolean( - ackReaction && - core.channel.reactions.shouldAckReaction({ - scope: ackScope, - isDirect: isDirectMessage, - isGroup: isRoom, - isMentionableGroup: isRoom, - requireMention: Boolean(shouldRequireMention), - canDetectMention, - effectiveWasMentioned: wasMentioned || shouldBypassMention, - shouldBypassMention, - }), - ); - if (shouldAckReaction() && messageId) { - reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { - logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); - }); - } - - const replyTarget = ctxPayload.To; - if (!replyTarget) { - runtime.error?.("matrix: missing reply target"); - return; - } - - if (messageId) { - sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { - logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, - ); - }); - } - - let didSendReply = false; - const tableMode = core.channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix-js", - accountId: route.accountId, - }); - const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ - cfg, - agentId: route.agentId, - channel: "matrix-js", - accountId: route.accountId, - }); - const typingCallbacks = createTypingCallbacks({ - start: () => sendTypingMatrix(roomId, true, undefined, client), - stop: () => sendTypingMatrix(roomId, false, undefined, client), - onStartError: (err) => { - logTypingFailure({ - log: logVerboseMessage, - channel: "matrix-js", - action: "start", - target: roomId, - error: err, - }); - }, - onStopError: (err) => { - logTypingFailure({ - log: logVerboseMessage, - channel: "matrix-js", - action: "stop", - target: roomId, - error: err, - }); - }, - }); - const { dispatcher, replyOptions, markDispatchIdle } = - core.channel.reply.createReplyDispatcherWithTyping({ - ...prefixOptions, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), - deliver: async (payload: ReplyPayload) => { - await deliverMatrixReplies({ - replies: [payload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - accountId: route.accountId, - tableMode, - }); - didSendReply = true; - }, - onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => { - runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); - }, - onReplyStart: typingCallbacks.onReplyStart, - onIdle: typingCallbacks.onIdle, - }); - - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - onModelSelected, - }, - }); - markDispatchIdle(); - if (!queuedFinal) { - return; - } - didSendReply = true; - const finalCount = counts.final; - logVerboseMessage( - `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, - ); - if (didSendReply) { - const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160); - core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, { - sessionKey: route.sessionKey, - contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, - }); - } - } catch (err) { - runtime.error?.(`matrix handler failed: ${String(err)}`); - } - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/index.ts b/extensions/matrix-js/src/matrix/monitor/index.ts deleted file mode 100644 index e691dab93d7..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/index.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { format } from "node:util"; -import { - GROUP_POLICY_BLOCKED_LABEL, - mergeAllowlist, - resolveThreadBindingIdleTimeoutMsForChannel, - resolveThreadBindingMaxAgeMsForChannel, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - summarizeMapping, - warnMissingProviderGroupPolicyFallbackOnce, - type RuntimeEnv, -} from "openclaw/plugin-sdk/matrix-js"; -import { resolveMatrixTargets } from "../../resolve-targets.js"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig, ReplyToMode } from "../../types.js"; -import { resolveMatrixAccount } from "../accounts.js"; -import { setActiveMatrixClient } from "../active-client.js"; -import { - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, - stopSharedClientForAccount, -} from "../client.js"; -import { updateMatrixAccountConfig } from "../config-update.js"; -import { syncMatrixOwnProfile } from "../profile.js"; -import { createMatrixThreadBindingManager } from "../thread-bindings.js"; -import { normalizeMatrixUserId } from "./allowlist.js"; -import { registerMatrixAutoJoin } from "./auto-join.js"; -import { createDirectRoomTracker } from "./direct.js"; -import { registerMatrixMonitorEvents } from "./events.js"; -import { createMatrixRoomMessageHandler } from "./handler.js"; -import { createMatrixRoomInfoResolver } from "./room-info.js"; -import { ensureMatrixStartupVerification } from "./startup-verification.js"; - -export type MonitorMatrixOpts = { - runtime?: RuntimeEnv; - abortSignal?: AbortSignal; - mediaMaxMb?: number; - initialSyncLimit?: number; - replyToMode?: ReplyToMode; - accountId?: string | null; -}; - -const DEFAULT_MEDIA_MAX_MB = 20; - -export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise { - if (isBunRuntime()) { - throw new Error("Matrix provider requires Node (bun runtime not supported)"); - } - const core = getMatrixRuntime(); - let cfg = core.config.loadConfig() as CoreConfig; - if (cfg.channels?.["matrix-js"]?.enabled === false) { - return; - } - - const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const formatRuntimeMessage = (...args: Parameters) => format(...args); - const runtime: RuntimeEnv = opts.runtime ?? { - log: (...args) => { - logger.info(formatRuntimeMessage(...args)); - }, - error: (...args) => { - logger.error(formatRuntimeMessage(...args)); - }, - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }; - const logVerboseMessage = (message: string) => { - if (!core.logging.shouldLogVerbose()) { - return; - } - logger.debug?.(message); - }; - - const normalizeUserEntry = (raw: string) => - raw - .replace(/^matrix:/i, "") - .replace(/^user:/i, "") - .trim(); - const normalizeRoomEntry = (raw: string) => - raw - .replace(/^matrix:/i, "") - .replace(/^(room|channel):/i, "") - .trim(); - const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); - const resolveUserAllowlist = async ( - label: string, - list?: Array, - ): Promise => { - let allowList = list ?? []; - if (allowList.length === 0) { - return allowList.map(String); - } - const entries = allowList - .map((entry) => normalizeUserEntry(String(entry))) - .filter((entry) => entry && entry !== "*"); - if (entries.length === 0) { - return allowList.map(String); - } - const mapping: string[] = []; - const unresolved: string[] = []; - const additions: string[] = []; - const pending: string[] = []; - for (const entry of entries) { - if (isMatrixUserId(entry)) { - additions.push(normalizeMatrixUserId(entry)); - continue; - } - pending.push(entry); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - runtime, - }); - for (const entry of resolved) { - if (entry.resolved && entry.id) { - const normalizedId = normalizeMatrixUserId(entry.id); - additions.push(normalizedId); - mapping.push(`${entry.input}→${normalizedId}`); - } else { - unresolved.push(entry.input); - } - } - } - allowList = mergeAllowlist({ existing: allowList, additions }); - summarizeMapping(label, mapping, unresolved, runtime); - if (unresolved.length > 0) { - runtime.log?.( - `${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, - ); - } - return allowList.map(String); - }; - - // Resolve account-specific config for multi-account support - const account = resolveMatrixAccount({ cfg, accountId: opts.accountId }); - const accountConfig = account.config; - - const allowlistOnly = accountConfig.allowlistOnly === true; - let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String); - let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); - let roomsConfig = accountConfig.groups ?? accountConfig.rooms; - - allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom); - groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); - - if (roomsConfig && Object.keys(roomsConfig).length > 0) { - const mapping: string[] = []; - const unresolved: string[] = []; - const nextRooms: Record = {}; - if (roomsConfig["*"]) { - nextRooms["*"] = roomsConfig["*"]; - } - const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> = - []; - for (const [entry, roomConfig] of Object.entries(roomsConfig)) { - if (entry === "*") { - continue; - } - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = normalizeRoomEntry(trimmed); - if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) { - if (!nextRooms[cleaned]) { - nextRooms[cleaned] = roomConfig; - } - if (cleaned !== entry) { - mapping.push(`${entry}→${cleaned}`); - } - continue; - } - pending.push({ input: entry, query: trimmed, config: roomConfig }); - } - if (pending.length > 0) { - const resolved = await resolveMatrixTargets({ - cfg, - inputs: pending.map((entry) => entry.query), - kind: "group", - runtime, - }); - resolved.forEach((entry, index) => { - const source = pending[index]; - if (!source) { - return; - } - if (entry.resolved && entry.id) { - if (!nextRooms[entry.id]) { - nextRooms[entry.id] = source.config; - } - mapping.push(`${source.input}→${entry.id}`); - } else { - unresolved.push(source.input); - } - }); - } - roomsConfig = nextRooms; - summarizeMapping("matrix rooms", mapping, unresolved, runtime); - if (unresolved.length > 0) { - runtime.log?.( - "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", - ); - } - } - if (roomsConfig && Object.keys(roomsConfig).length > 0) { - const nextRooms = { ...roomsConfig }; - for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) { - const users = roomConfig?.users ?? []; - if (users.length === 0) { - continue; - } - const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users); - if (resolvedUsers !== users) { - nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers }; - } - } - roomsConfig = nextRooms; - } - - cfg = { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": { - ...cfg.channels?.["matrix-js"], - dm: { - ...cfg.channels?.["matrix-js"]?.dm, - allowFrom, - }, - groupAllowFrom, - ...(roomsConfig ? { groups: roomsConfig } : {}), - }, - }, - }; - - const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId }); - const resolvedInitialSyncLimit = - typeof opts.initialSyncLimit === "number" - ? Math.max(0, Math.floor(opts.initialSyncLimit)) - : auth.initialSyncLimit; - const authWithLimit = - resolvedInitialSyncLimit === auth.initialSyncLimit - ? auth - : { ...auth, initialSyncLimit: resolvedInitialSyncLimit }; - const client = await resolveSharedMatrixClient({ - cfg, - auth: authWithLimit, - startClient: false, - accountId: opts.accountId ?? undefined, - }); - setActiveMatrixClient(client, opts.accountId); - - const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); - const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); - const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = - resolveAllowlistProviderRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.["matrix-js"] !== undefined, - groupPolicy: accountConfig.groupPolicy, - defaultGroupPolicy, - }); - warnMissingProviderGroupPolicyFallbackOnce({ - providerMissingFallbackApplied, - providerKey: "matrix-js", - accountId: account.accountId, - blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room, - log: (message) => logVerboseMessage(message), - }); - const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; - const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off"; - const threadReplies = accountConfig.threadReplies ?? "inbound"; - const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({ - cfg, - channel: "matrix-js", - accountId: account.accountId, - }); - const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMsForChannel({ - cfg, - channel: "matrix-js", - accountId: account.accountId, - }); - const dmConfig = accountConfig.dm; - const dmEnabled = dmConfig?.enabled ?? true; - const dmPolicyRaw = dmConfig?.policy ?? "pairing"; - const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; - const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix-js"); - const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; - const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; - const startupMs = Date.now(); - const startupGraceMs = 0; - const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); - registerMatrixAutoJoin({ client, cfg, runtime }); - const warnedEncryptedRooms = new Set(); - const warnedCryptoMissingRooms = new Set(); - - const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); - const handleRoomMessage = createMatrixRoomMessageHandler({ - client, - core, - cfg, - accountId: account.accountId, - runtime, - logger, - logVerboseMessage, - allowFrom, - roomsConfig, - mentionRegexes, - groupPolicy, - replyToMode, - threadReplies, - dmEnabled, - dmPolicy, - textLimit, - mediaMaxBytes, - startupMs, - startupGraceMs, - directTracker, - getRoomInfo, - getMemberDisplayName, - }); - - registerMatrixMonitorEvents({ - client, - auth, - logVerboseMessage, - warnedEncryptedRooms, - warnedCryptoMissingRooms, - logger, - formatNativeDependencyHint: core.system.formatNativeDependencyHint, - onRoomMessage: handleRoomMessage, - }); - - logVerboseMessage("matrix: starting client"); - await resolveSharedMatrixClient({ - cfg, - auth: authWithLimit, - accountId: opts.accountId, - }); - logVerboseMessage("matrix: client started"); - const threadBindingManager = await createMatrixThreadBindingManager({ - accountId: account.accountId, - auth, - client, - env: process.env, - idleTimeoutMs: threadBindingIdleTimeoutMs, - maxAgeMs: threadBindingMaxAgeMs, - logVerboseMessage, - }); - logVerboseMessage( - `matrix: thread bindings ready account=${threadBindingManager.accountId} idleMs=${threadBindingIdleTimeoutMs} maxAgeMs=${threadBindingMaxAgeMs}`, - ); - - // Shared client is already started via resolveSharedMatrixClient. - logger.info(`matrix: logged in as ${auth.userId}`); - - try { - const profileSync = await syncMatrixOwnProfile({ - client, - userId: auth.userId, - displayName: accountConfig.name, - avatarUrl: accountConfig.avatarUrl, - loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), - }); - if (profileSync.displayNameUpdated) { - logger.info(`matrix: profile display name updated for ${auth.userId}`); - } - if (profileSync.avatarUpdated) { - logger.info(`matrix: profile avatar updated for ${auth.userId}`); - } - if ( - profileSync.convertedAvatarFromHttp && - profileSync.resolvedAvatarUrl && - accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl - ) { - const latestCfg = core.config.loadConfig() as CoreConfig; - const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, { - avatarUrl: profileSync.resolvedAvatarUrl, - }); - await core.config.writeConfigFile(updatedCfg as never); - logVerboseMessage( - `matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`, - ); - } - } catch (err) { - logger.warn("matrix: failed to sync profile from config", { error: String(err) }); - } - - // If E2EE is enabled, report device verification status and request self-verification - // when configured and the device is still unverified. - if (auth.encryption && client.crypto) { - try { - const startupVerification = await ensureMatrixStartupVerification({ - client, - auth, - accountConfig, - accountId: account.accountId, - env: process.env, - }); - if (startupVerification.kind === "verified") { - logger.info("matrix: device is verified and ready for encrypted rooms"); - } else if ( - startupVerification.kind === "disabled" || - startupVerification.kind === "cooldown" || - startupVerification.kind === "pending" || - startupVerification.kind === "request-failed" - ) { - logger.info( - "matrix: device not verified — run 'openclaw matrix-js verify device ' to enable E2EE", - ); - if (startupVerification.kind === "pending") { - logger.info( - "matrix: startup verification request is already pending; finish it in another Matrix client", - ); - } else if (startupVerification.kind === "cooldown") { - logVerboseMessage( - `matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`, - ); - } else if (startupVerification.kind === "request-failed") { - logger.debug?.("Matrix startup verification request failed (non-fatal)", { - error: startupVerification.error ?? "unknown", - }); - } - } else if (startupVerification.kind === "requested") { - logger.info( - "matrix: device not verified — requested verification in another Matrix client", - ); - } - } catch (err) { - logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", { - error: String(err), - }); - } - } - - await new Promise((resolve) => { - const onAbort = () => { - try { - threadBindingManager.stop(); - logVerboseMessage("matrix: stopping client"); - stopSharedClientForAccount(auth, opts.accountId); - } finally { - setActiveMatrixClient(null, opts.accountId); - resolve(); - } - }; - if (opts.abortSignal?.aborted) { - onAbort(); - return; - } - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); - }); -} diff --git a/extensions/matrix-js/src/matrix/monitor/location.ts b/extensions/matrix-js/src/matrix/monitor/location.ts deleted file mode 100644 index 582c4b4facf..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/location.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - formatLocationText, - toLocationContext, - type NormalizedLocation, -} from "openclaw/plugin-sdk/matrix-js"; -import type { LocationMessageEventContent } from "../sdk.js"; -import { EventType } from "./types.js"; - -export type MatrixLocationPayload = { - text: string; - context: ReturnType; -}; - -type GeoUriParams = { - latitude: number; - longitude: number; - accuracy?: number; -}; - -function parseGeoUri(value: string): GeoUriParams | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (!trimmed.toLowerCase().startsWith("geo:")) { - return null; - } - const payload = trimmed.slice(4); - const [coordsPart, ...paramParts] = payload.split(";"); - const coords = coordsPart.split(","); - if (coords.length < 2) { - return null; - } - const latitude = Number.parseFloat(coords[0] ?? ""); - const longitude = Number.parseFloat(coords[1] ?? ""); - if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { - return null; - } - - const params = new Map(); - for (const part of paramParts) { - const segment = part.trim(); - if (!segment) { - continue; - } - const eqIndex = segment.indexOf("="); - const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex); - const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1); - const key = rawKey.trim().toLowerCase(); - if (!key) { - continue; - } - const valuePart = rawValue.trim(); - params.set(key, valuePart ? decodeURIComponent(valuePart) : ""); - } - - const accuracyRaw = params.get("u"); - const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined; - - return { - latitude, - longitude, - accuracy: Number.isFinite(accuracy) ? accuracy : undefined, - }; -} - -export function resolveMatrixLocation(params: { - eventType: string; - content: LocationMessageEventContent; -}): MatrixLocationPayload | null { - const { eventType, content } = params; - const isLocation = - eventType === EventType.Location || - (eventType === EventType.RoomMessage && content.msgtype === EventType.Location); - if (!isLocation) { - return null; - } - const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : ""; - if (!geoUri) { - return null; - } - const parsed = parseGeoUri(geoUri); - if (!parsed) { - return null; - } - const caption = typeof content.body === "string" ? content.body.trim() : ""; - const location: NormalizedLocation = { - latitude: parsed.latitude, - longitude: parsed.longitude, - accuracy: parsed.accuracy, - caption: caption || undefined, - source: "pin", - isLive: false, - }; - - return { - text: formatLocationText(location), - context: toLocationContext(location), - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/media.test.ts b/extensions/matrix-js/src/matrix/monitor/media.test.ts deleted file mode 100644 index 7127b1fc67c..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/media.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setMatrixRuntime } from "../../runtime.js"; -import { downloadMatrixMedia } from "./media.js"; - -describe("downloadMatrixMedia", () => { - const saveMediaBuffer = vi.fn().mockResolvedValue({ - path: "/tmp/media", - contentType: "image/png", - }); - - const runtimeStub = { - channel: { - media: { - saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), - }, - }, - } as unknown as PluginRuntime; - - beforeEach(() => { - vi.clearAllMocks(); - setMatrixRuntime(runtimeStub); - }); - - it("decrypts encrypted media when file payloads are present", async () => { - const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - - const client = { - crypto: { decryptMedia }, - mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("../sdk.js").MatrixClient; - - const file = { - url: "mxc://example/file", - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }; - - const result = await downloadMatrixMedia({ - client, - mxcUrl: "mxc://example/file", - contentType: "image/png", - maxBytes: 1024, - file, - }); - - // decryptMedia should be called with just the file object (it handles download internally) - expect(decryptMedia).toHaveBeenCalledWith(file); - expect(saveMediaBuffer).toHaveBeenCalledWith( - Buffer.from("decrypted"), - "image/png", - "inbound", - 1024, - ); - expect(result?.path).toBe("/tmp/media"); - }); - - it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { - const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); - - const client = { - crypto: { decryptMedia }, - mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("../sdk.js").MatrixClient; - - const file = { - url: "mxc://example/file", - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }; - - await expect( - downloadMatrixMedia({ - client, - mxcUrl: "mxc://example/file", - contentType: "image/png", - sizeBytes: 2048, - maxBytes: 1024, - file, - }), - ).rejects.toThrow("Matrix media exceeds configured size limit"); - - expect(decryptMedia).not.toHaveBeenCalled(); - expect(saveMediaBuffer).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/media.ts b/extensions/matrix-js/src/matrix/monitor/media.ts deleted file mode 100644 index bfd31b51138..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/media.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { getMatrixRuntime } from "../../runtime.js"; -import type { MatrixClient } from "../sdk.js"; - -// Type for encrypted file info -type EncryptedFile = { - url: string; - key: { - kty: string; - key_ops: string[]; - alg: string; - k: string; - ext: boolean; - }; - iv: string; - hashes: Record; - v: string; -}; - -async function fetchMatrixMediaBuffer(params: { - client: MatrixClient; - mxcUrl: string; - maxBytes: number; -}): Promise<{ buffer: Buffer } | null> { - // The client wrapper exposes mxcToHttp for Matrix media URIs. - const url = params.client.mxcToHttp(params.mxcUrl); - if (!url) { - return null; - } - - // Use the client's download method which handles auth - try { - const raw = await params.client.downloadContent(params.mxcUrl); - const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); - - if (buffer.byteLength > params.maxBytes) { - throw new Error("Matrix media exceeds configured size limit"); - } - return { buffer }; - } catch (err) { - throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err }); - } -} - -/** - * Download and decrypt encrypted media from a Matrix room. - * Uses the Matrix crypto adapter's decryptMedia helper. - */ -async function fetchEncryptedMediaBuffer(params: { - client: MatrixClient; - file: EncryptedFile; - maxBytes: number; -}): Promise<{ buffer: Buffer } | null> { - if (!params.client.crypto) { - throw new Error("Cannot decrypt media: crypto not enabled"); - } - - // decryptMedia handles downloading and decrypting the encrypted content internally - const decrypted = await params.client.crypto.decryptMedia( - params.file as Parameters[0], - ); - - if (decrypted.byteLength > params.maxBytes) { - throw new Error("Matrix media exceeds configured size limit"); - } - - return { buffer: decrypted }; -} - -export async function downloadMatrixMedia(params: { - client: MatrixClient; - mxcUrl: string; - contentType?: string; - sizeBytes?: number; - maxBytes: number; - file?: EncryptedFile; -}): Promise<{ - path: string; - contentType?: string; - placeholder: string; -} | null> { - let fetched: { buffer: Buffer; headerType?: string } | null; - if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) { - throw new Error("Matrix media exceeds configured size limit"); - } - - if (params.file) { - // Encrypted media - fetched = await fetchEncryptedMediaBuffer({ - client: params.client, - file: params.file, - maxBytes: params.maxBytes, - }); - } else { - // Unencrypted media - fetched = await fetchMatrixMediaBuffer({ - client: params.client, - mxcUrl: params.mxcUrl, - maxBytes: params.maxBytes, - }); - } - - if (!fetched) { - return null; - } - const headerType = params.contentType ?? undefined; - const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( - fetched.buffer, - headerType, - "inbound", - params.maxBytes, - ); - return { - path: saved.path, - contentType: saved.contentType, - placeholder: "[matrix media]", - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/mentions.test.ts b/extensions/matrix-js/src/matrix/monitor/mentions.test.ts deleted file mode 100644 index f1ee615e7ef..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/mentions.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -// Mock the runtime before importing resolveMentions -vi.mock("../../runtime.js", () => ({ - getMatrixRuntime: () => ({ - channel: { - mentions: { - matchesMentionPatterns: (text: string, patterns: RegExp[]) => - patterns.some((p) => p.test(text)), - }, - }, - }), -})); - -import { resolveMentions } from "./mentions.js"; - -describe("resolveMentions", () => { - const userId = "@bot:matrix.org"; - const mentionRegexes = [/@bot/i]; - - describe("m.mentions field", () => { - it("detects mention via m.mentions.user_ids", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "hello", - "m.mentions": { user_ids: ["@bot:matrix.org"] }, - }, - userId, - text: "hello", - mentionRegexes, - }); - expect(result.wasMentioned).toBe(true); - expect(result.hasExplicitMention).toBe(true); - }); - - it("detects room mention via m.mentions.room", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "hello everyone", - "m.mentions": { room: true }, - }, - userId, - text: "hello everyone", - mentionRegexes, - }); - expect(result.wasMentioned).toBe(true); - }); - }); - - describe("formatted_body matrix.to links", () => { - it("detects mention in formatted_body with plain user ID", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "Bot: hello", - formatted_body: 'Bot: hello', - }, - userId, - text: "Bot: hello", - mentionRegexes: [], - }); - expect(result.wasMentioned).toBe(true); - }); - - it("detects mention in formatted_body with URL-encoded user ID", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "Bot: hello", - formatted_body: 'Bot: hello', - }, - userId, - text: "Bot: hello", - mentionRegexes: [], - }); - expect(result.wasMentioned).toBe(true); - }); - - it("detects mention with single quotes in href", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "Bot: hello", - formatted_body: "Bot: hello", - }, - userId, - text: "Bot: hello", - mentionRegexes: [], - }); - expect(result.wasMentioned).toBe(true); - }); - - it("does not detect mention for different user ID", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "Other: hello", - formatted_body: 'Other: hello', - }, - userId, - text: "Other: hello", - mentionRegexes: [], - }); - expect(result.wasMentioned).toBe(false); - }); - - it("does not false-positive on partial user ID match", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "Bot2: hello", - formatted_body: 'Bot2: hello', - }, - userId: "@bot:matrix.org", - text: "Bot2: hello", - mentionRegexes: [], - }); - expect(result.wasMentioned).toBe(false); - }); - }); - - describe("regex patterns", () => { - it("detects mention via regex pattern in body text", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "hey @bot can you help?", - }, - userId, - text: "hey @bot can you help?", - mentionRegexes, - }); - expect(result.wasMentioned).toBe(true); - }); - }); - - describe("no mention", () => { - it("returns false when no mention is present", () => { - const result = resolveMentions({ - content: { - msgtype: "m.text", - body: "hello world", - }, - userId, - text: "hello world", - mentionRegexes, - }); - expect(result.wasMentioned).toBe(false); - expect(result.hasExplicitMention).toBe(false); - }); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/mentions.ts b/extensions/matrix-js/src/matrix/monitor/mentions.ts deleted file mode 100644 index aa67386221a..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/mentions.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getMatrixRuntime } from "../../runtime.js"; -import type { RoomMessageEventContent } from "./types.js"; - -/** - * Check if the formatted_body contains a matrix.to mention link for the given user ID. - * Many Matrix clients (including Element) use HTML links in formatted_body instead of - * or in addition to the m.mentions field. - */ -function checkFormattedBodyMention(formattedBody: string | undefined, userId: string): boolean { - if (!formattedBody || !userId) { - return false; - } - // Escape special regex characters in the user ID (e.g., @user:matrix.org) - const escapedUserId = userId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Match matrix.to links with the user ID, handling both URL-encoded and plain formats - // Example: href="https://matrix.to/#/@user:matrix.org" or href="https://matrix.to/#/%40user%3Amatrix.org" - const plainPattern = new RegExp(`href=["']https://matrix\\.to/#/${escapedUserId}["']`, "i"); - if (plainPattern.test(formattedBody)) { - return true; - } - // Also check URL-encoded version (@ -> %40, : -> %3A) - const encodedUserId = encodeURIComponent(userId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const encodedPattern = new RegExp(`href=["']https://matrix\\.to/#/${encodedUserId}["']`, "i"); - return encodedPattern.test(formattedBody); -} - -export function resolveMentions(params: { - content: RoomMessageEventContent; - userId?: string | null; - text?: string; - mentionRegexes: RegExp[]; -}) { - const mentions = params.content["m.mentions"]; - const mentionedUsers = Array.isArray(mentions?.user_ids) - ? new Set(mentions.user_ids) - : new Set(); - - // Check formatted_body for matrix.to mention links (legacy/alternative mention format) - const mentionedInFormattedBody = params.userId - ? checkFormattedBodyMention(params.content.formatted_body, params.userId) - : false; - - const wasMentioned = - Boolean(mentions?.room) || - (params.userId ? mentionedUsers.has(params.userId) : false) || - mentionedInFormattedBody || - getMatrixRuntime().channel.mentions.matchesMentionPatterns( - params.text ?? "", - params.mentionRegexes, - ); - return { wasMentioned, hasExplicitMention: Boolean(mentions) }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/replies.test.ts b/extensions/matrix-js/src/matrix/monitor/replies.test.ts deleted file mode 100644 index 326eafdf1c1..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/replies.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "../sdk.js"; - -const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" })); - -vi.mock("../send.js", () => ({ - sendMessageMatrix: (to: string, message: string, opts?: unknown) => - sendMessageMatrixMock(to, message, opts), -})); - -import { setMatrixRuntime } from "../../runtime.js"; -import { deliverMatrixReplies } from "./replies.js"; - -describe("deliverMatrixReplies", () => { - const loadConfigMock = vi.fn(() => ({})); - const resolveMarkdownTableModeMock = vi.fn(() => "code"); - const convertMarkdownTablesMock = vi.fn((text: string) => text); - const resolveChunkModeMock = vi.fn(() => "length"); - const chunkMarkdownTextWithModeMock = vi.fn((text: string) => [text]); - - const runtimeStub = { - config: { - loadConfig: () => loadConfigMock(), - }, - channel: { - text: { - resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(), - convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text), - resolveChunkMode: () => resolveChunkModeMock(), - chunkMarkdownTextWithMode: (text: string) => chunkMarkdownTextWithModeMock(text), - }, - }, - logging: { - shouldLogVerbose: () => false, - }, - } as unknown as PluginRuntime; - - const runtimeEnv: RuntimeEnv = { - log: vi.fn(), - error: vi.fn(), - } as unknown as RuntimeEnv; - - beforeEach(() => { - vi.clearAllMocks(); - setMatrixRuntime(runtimeStub); - chunkMarkdownTextWithModeMock.mockImplementation((text: string) => [text]); - }); - - it("keeps replyToId on first reply only when replyToMode=first", async () => { - chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); - - await deliverMatrixReplies({ - replies: [ - { text: "first-a|first-b", replyToId: "reply-1" }, - { text: "second", replyToId: "reply-2" }, - ], - roomId: "room:1", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "first", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3); - expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual( - expect.objectContaining({ replyToId: "reply-1", threadId: undefined }), - ); - expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual( - expect.objectContaining({ replyToId: "reply-1", threadId: undefined }), - ); - expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual( - expect.objectContaining({ replyToId: undefined, threadId: undefined }), - ); - }); - - it("keeps replyToId on every reply when replyToMode=all", async () => { - await deliverMatrixReplies({ - replies: [ - { - text: "caption", - mediaUrls: ["https://example.com/a.jpg", "https://example.com/b.jpg"], - replyToId: "reply-media", - audioAsVoice: true, - }, - { text: "plain", replyToId: "reply-text" }, - ], - roomId: "room:2", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "all", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(3); - expect(sendMessageMatrixMock.mock.calls[0]).toEqual([ - "room:2", - "caption", - expect.objectContaining({ mediaUrl: "https://example.com/a.jpg", replyToId: "reply-media" }), - ]); - expect(sendMessageMatrixMock.mock.calls[1]).toEqual([ - "room:2", - "", - expect.objectContaining({ mediaUrl: "https://example.com/b.jpg", replyToId: "reply-media" }), - ]); - expect(sendMessageMatrixMock.mock.calls[2]?.[2]).toEqual( - expect.objectContaining({ replyToId: "reply-text" }), - ); - }); - - it("suppresses replyToId when threadId is set", async () => { - chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|")); - - await deliverMatrixReplies({ - replies: [{ text: "hello|thread", replyToId: "reply-thread" }], - roomId: "room:3", - client: {} as MatrixClient, - runtime: runtimeEnv, - textLimit: 4000, - replyToMode: "all", - threadId: "thread-77", - }); - - expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); - expect(sendMessageMatrixMock.mock.calls[0]?.[2]).toEqual( - expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }), - ); - expect(sendMessageMatrixMock.mock.calls[1]?.[2]).toEqual( - expect.objectContaining({ replyToId: undefined, threadId: "thread-77" }), - ); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/replies.ts b/extensions/matrix-js/src/matrix/monitor/replies.ts deleted file mode 100644 index fb4f4d24ac4..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/replies.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix-js"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { MatrixClient } from "../sdk.js"; -import { sendMessageMatrix } from "../send.js"; - -export async function deliverMatrixReplies(params: { - replies: ReplyPayload[]; - roomId: string; - client: MatrixClient; - runtime: RuntimeEnv; - textLimit: number; - replyToMode: "off" | "first" | "all"; - threadId?: string; - accountId?: string; - tableMode?: MarkdownTableMode; -}): Promise { - const core = getMatrixRuntime(); - const cfg = core.config.loadConfig(); - const tableMode = - params.tableMode ?? - core.channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix-js", - accountId: params.accountId, - }); - const logVerbose = (message: string) => { - if (core.logging.shouldLogVerbose()) { - params.runtime.log?.(message); - } - }; - const chunkLimit = Math.min(params.textLimit, 4000); - const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix-js", params.accountId); - let hasReplied = false; - for (const reply of params.replies) { - const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; - if (!reply?.text && !hasMedia) { - if (reply?.audioAsVoice) { - logVerbose("matrix reply has audioAsVoice without media/text; skipping"); - continue; - } - params.runtime.error?.("matrix reply missing text/media"); - continue; - } - const replyToIdRaw = reply.replyToId?.trim(); - const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; - const rawText = reply.text ?? ""; - const text = core.channel.text.convertMarkdownTables(rawText, tableMode); - const mediaList = reply.mediaUrls?.length - ? reply.mediaUrls - : reply.mediaUrl - ? [reply.mediaUrl] - : []; - - const shouldIncludeReply = (id?: string) => - Boolean(id) && (params.replyToMode === "all" || !hasReplied); - const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; - - if (mediaList.length === 0) { - let sentTextChunk = false; - for (const chunk of core.channel.text.chunkMarkdownTextWithMode( - text, - chunkLimit, - chunkMode, - )) { - const trimmed = chunk.trim(); - if (!trimmed) { - continue; - } - await sendMessageMatrix(params.roomId, trimmed, { - client: params.client, - replyToId: replyToIdForReply, - threadId: params.threadId, - accountId: params.accountId, - }); - sentTextChunk = true; - } - if (replyToIdForReply && !hasReplied && sentTextChunk) { - hasReplied = true; - } - continue; - } - - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - await sendMessageMatrix(params.roomId, caption, { - client: params.client, - mediaUrl, - replyToId: replyToIdForReply, - threadId: params.threadId, - audioAsVoice: reply.audioAsVoice, - accountId: params.accountId, - }); - first = false; - } - if (replyToIdForReply && !hasReplied) { - hasReplied = true; - } - } -} diff --git a/extensions/matrix-js/src/matrix/monitor/room-info.ts b/extensions/matrix-js/src/matrix/monitor/room-info.ts deleted file mode 100644 index 095f1dc307a..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/room-info.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { MatrixClient } from "../sdk.js"; - -export type MatrixRoomInfo = { - name?: string; - canonicalAlias?: string; - altAliases: string[]; -}; - -export function createMatrixRoomInfoResolver(client: MatrixClient) { - const roomInfoCache = new Map(); - - const getRoomInfo = async (roomId: string): Promise => { - const cached = roomInfoCache.get(roomId); - if (cached) { - return cached; - } - let name: string | undefined; - let canonicalAlias: string | undefined; - let altAliases: string[] = []; - try { - const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null); - if (nameState && typeof nameState.name === "string") { - name = nameState.name; - } - } catch { - // ignore - } - try { - const aliasState = await client - .getRoomStateEvent(roomId, "m.room.canonical_alias", "") - .catch(() => null); - if (aliasState && typeof aliasState.alias === "string") { - canonicalAlias = aliasState.alias; - } - const rawAliases = aliasState?.alt_aliases; - if (Array.isArray(rawAliases)) { - altAliases = rawAliases.filter((entry): entry is string => typeof entry === "string"); - } - } catch { - // ignore - } - const info = { name, canonicalAlias, altAliases }; - roomInfoCache.set(roomId, info); - return info; - }; - - const getMemberDisplayName = async (roomId: string, userId: string): Promise => { - try { - const memberState = await client - .getRoomStateEvent(roomId, "m.room.member", userId) - .catch(() => null); - if (memberState && typeof memberState.displayname === "string") { - return memberState.displayname; - } - return userId; - } catch { - return userId; - } - }; - - return { - getRoomInfo, - getMemberDisplayName, - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/rooms.test.ts b/extensions/matrix-js/src/matrix/monitor/rooms.test.ts deleted file mode 100644 index 21fe5a90474..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/rooms.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveMatrixRoomConfig } from "./rooms.js"; - -describe("resolveMatrixRoomConfig", () => { - it("matches room IDs and aliases, not names", () => { - const rooms = { - "!room:example.org": { allow: true }, - "#alias:example.org": { allow: true }, - "Project Room": { allow: true }, - }; - - const byId = resolveMatrixRoomConfig({ - rooms, - roomId: "!room:example.org", - aliases: [], - name: "Project Room", - }); - expect(byId.allowed).toBe(true); - expect(byId.matchKey).toBe("!room:example.org"); - - const byAlias = resolveMatrixRoomConfig({ - rooms, - roomId: "!other:example.org", - aliases: ["#alias:example.org"], - name: "Other Room", - }); - expect(byAlias.allowed).toBe(true); - expect(byAlias.matchKey).toBe("#alias:example.org"); - - const byName = resolveMatrixRoomConfig({ - rooms: { "Project Room": { allow: true } }, - roomId: "!different:example.org", - aliases: [], - name: "Project Room", - }); - expect(byName.allowed).toBe(false); - expect(byName.config).toBeUndefined(); - }); -}); diff --git a/extensions/matrix-js/src/matrix/monitor/rooms.ts b/extensions/matrix-js/src/matrix/monitor/rooms.ts deleted file mode 100644 index b52c10ef3f2..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/rooms.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk/matrix-js"; -import type { MatrixRoomConfig } from "../../types.js"; - -export type MatrixRoomConfigResolved = { - allowed: boolean; - allowlistConfigured: boolean; - config?: MatrixRoomConfig; - matchKey?: string; - matchSource?: "direct" | "wildcard"; -}; - -export function resolveMatrixRoomConfig(params: { - rooms?: Record; - roomId: string; - aliases: string[]; - name?: string | null; -}): MatrixRoomConfigResolved { - const rooms = params.rooms ?? {}; - const keys = Object.keys(rooms); - const allowlistConfigured = keys.length > 0; - const candidates = buildChannelKeyCandidates( - params.roomId, - `room:${params.roomId}`, - ...params.aliases, - ); - const { - entry: matched, - key: matchedKey, - wildcardEntry, - wildcardKey, - } = resolveChannelEntryMatch({ - entries: rooms, - keys: candidates, - wildcardKey: "*", - }); - const resolved = matched ?? wildcardEntry; - const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false; - const matchKey = matchedKey ?? wildcardKey; - const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined; - return { - allowed, - allowlistConfigured, - config: resolved, - matchKey, - matchSource, - }; -} diff --git a/extensions/matrix-js/src/matrix/monitor/threads.ts b/extensions/matrix-js/src/matrix/monitor/threads.ts deleted file mode 100644 index 3c90e08dbfd..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/threads.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; -import { RelationType } from "./types.js"; - -export function resolveMatrixThreadTarget(params: { - threadReplies: "off" | "inbound" | "always"; - messageId: string; - threadRootId?: string; - isThreadRoot?: boolean; -}): string | undefined { - const { threadReplies, messageId, threadRootId } = params; - if (threadReplies === "off") { - return undefined; - } - const isThreadRoot = params.isThreadRoot === true; - const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot); - if (threadReplies === "inbound") { - return hasInboundThread ? threadRootId : undefined; - } - if (threadReplies === "always") { - return threadRootId ?? messageId; - } - return undefined; -} - -export function resolveMatrixThreadRootId(params: { - event: MatrixRawEvent; - content: RoomMessageEventContent; -}): string | undefined { - const relates = params.content["m.relates_to"]; - if (!relates || typeof relates !== "object") { - return undefined; - } - if ("rel_type" in relates && relates.rel_type === RelationType.Thread) { - if ("event_id" in relates && typeof relates.event_id === "string") { - return relates.event_id; - } - if ( - "m.in_reply_to" in relates && - typeof relates["m.in_reply_to"] === "object" && - relates["m.in_reply_to"] && - "event_id" in relates["m.in_reply_to"] && - typeof relates["m.in_reply_to"].event_id === "string" - ) { - return relates["m.in_reply_to"].event_id; - } - } - return undefined; -} diff --git a/extensions/matrix-js/src/matrix/monitor/types.ts b/extensions/matrix-js/src/matrix/monitor/types.ts deleted file mode 100644 index 83552931906..00000000000 --- a/extensions/matrix-js/src/matrix/monitor/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js"; -import type { EncryptedFile, MessageEventContent } from "../sdk.js"; -export type { MatrixRawEvent } from "../sdk.js"; - -export const EventType = { - RoomMessage: "m.room.message", - RoomMessageEncrypted: "m.room.encrypted", - RoomMember: "m.room.member", - Location: "m.location", - Reaction: MATRIX_REACTION_EVENT_TYPE, -} as const; - -export const RelationType = { - Replace: "m.replace", - Thread: "m.thread", -} as const; - -export type RoomMessageEventContent = MessageEventContent & { - url?: string; - file?: EncryptedFile; - info?: { - mimetype?: string; - size?: number; - }; - "m.relates_to"?: { - rel_type?: string; - event_id?: string; - "m.in_reply_to"?: { event_id?: string }; - }; -}; diff --git a/extensions/matrix-js/src/matrix/poll-types.test.ts b/extensions/matrix-js/src/matrix/poll-types.test.ts deleted file mode 100644 index 3c78ab1b07c..00000000000 --- a/extensions/matrix-js/src/matrix/poll-types.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildPollResponseContent, - buildPollStartContent, - parsePollStart, - parsePollStartContent, -} from "./poll-types.js"; - -describe("parsePollStartContent", () => { - it("parses legacy m.poll payloads", () => { - const summary = parsePollStartContent({ - "m.poll": { - question: { "m.text": "Lunch?" }, - kind: "m.poll.disclosed", - max_selections: 1, - answers: [ - { id: "answer1", "m.text": "Yes" }, - { id: "answer2", "m.text": "No" }, - ], - }, - }); - - expect(summary?.question).toBe("Lunch?"); - expect(summary?.answers).toEqual(["Yes", "No"]); - }); - - it("preserves answer ids when parsing poll start content", () => { - const parsed = parsePollStart({ - "m.poll.start": { - question: { "m.text": "Lunch?" }, - kind: "m.poll.disclosed", - max_selections: 1, - answers: [ - { id: "a1", "m.text": "Yes" }, - { id: "a2", "m.text": "No" }, - ], - }, - }); - - expect(parsed).toMatchObject({ - question: "Lunch?", - answers: [ - { id: "a1", text: "Yes" }, - { id: "a2", text: "No" }, - ], - maxSelections: 1, - }); - }); - - it("caps invalid remote max selections to the available answer count", () => { - const parsed = parsePollStart({ - "m.poll.start": { - question: { "m.text": "Lunch?" }, - kind: "m.poll.undisclosed", - max_selections: 99, - answers: [ - { id: "a1", "m.text": "Yes" }, - { id: "a2", "m.text": "No" }, - ], - }, - }); - - expect(parsed?.maxSelections).toBe(2); - }); -}); - -describe("buildPollStartContent", () => { - it("preserves the requested multiselect cap instead of widening to all answers", () => { - const content = buildPollStartContent({ - question: "Lunch?", - options: ["Pizza", "Sushi", "Tacos"], - maxSelections: 2, - }); - - expect(content["m.poll.start"]?.max_selections).toBe(2); - expect(content["m.poll.start"]?.kind).toBe("m.poll.undisclosed"); - }); -}); - -describe("buildPollResponseContent", () => { - it("builds a poll response payload with a reference relation", () => { - expect(buildPollResponseContent("$poll", ["a2"])).toEqual({ - "m.poll.response": { - answers: ["a2"], - }, - "org.matrix.msc3381.poll.response": { - answers: ["a2"], - }, - "m.relates_to": { - rel_type: "m.reference", - event_id: "$poll", - }, - }); - }); -}); diff --git a/extensions/matrix-js/src/matrix/poll-types.ts b/extensions/matrix-js/src/matrix/poll-types.ts deleted file mode 100644 index cb287056731..00000000000 --- a/extensions/matrix-js/src/matrix/poll-types.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Matrix Poll Types (MSC3381) - * - * Defines types for Matrix poll events: - * - m.poll.start - Creates a new poll - * - m.poll.response - Records a vote - * - m.poll.end - Closes a poll - */ - -import { normalizePollInput, type PollInput } from "openclaw/plugin-sdk/matrix-js"; - -export const M_POLL_START = "m.poll.start" as const; -export const M_POLL_RESPONSE = "m.poll.response" as const; -export const M_POLL_END = "m.poll.end" as const; - -export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const; -export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const; -export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const; - -export const POLL_EVENT_TYPES = [ - M_POLL_START, - M_POLL_RESPONSE, - M_POLL_END, - ORG_POLL_START, - ORG_POLL_RESPONSE, - ORG_POLL_END, -]; - -export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START]; -export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE]; -export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END]; - -export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; - -export type TextContent = { - "m.text"?: string; - "org.matrix.msc1767.text"?: string; - body?: string; -}; - -export type PollAnswer = { - id: string; -} & TextContent; - -export type PollParsedAnswer = { - id: string; - text: string; -}; - -export type PollStartSubtype = { - question: TextContent; - kind?: PollKind; - max_selections?: number; - answers: PollAnswer[]; -}; - -export type LegacyPollStartContent = { - "m.poll"?: PollStartSubtype; -}; - -export type PollStartContent = { - [M_POLL_START]?: PollStartSubtype; - [ORG_POLL_START]?: PollStartSubtype; - "m.poll"?: PollStartSubtype; - "m.text"?: string; - "org.matrix.msc1767.text"?: string; -}; - -export type PollSummary = { - eventId: string; - roomId: string; - sender: string; - senderName: string; - question: string; - answers: string[]; - kind: PollKind; - maxSelections: number; -}; - -export type ParsedPollStart = { - question: string; - answers: PollParsedAnswer[]; - kind: PollKind; - maxSelections: number; -}; - -export type PollResponseSubtype = { - answers: string[]; -}; - -export type PollResponseContent = { - [M_POLL_RESPONSE]?: PollResponseSubtype; - [ORG_POLL_RESPONSE]?: PollResponseSubtype; - "m.relates_to": { - rel_type: "m.reference"; - event_id: string; - }; -}; - -export function isPollStartType(eventType: string): boolean { - return (POLL_START_TYPES as readonly string[]).includes(eventType); -} - -export function getTextContent(text?: TextContent): string { - if (!text) { - return ""; - } - return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; -} - -export function parsePollStart(content: PollStartContent): ParsedPollStart | null { - const poll = - (content as Record)[M_POLL_START] ?? - (content as Record)[ORG_POLL_START] ?? - (content as Record)["m.poll"]; - if (!poll) { - return null; - } - - const question = getTextContent(poll.question).trim(); - if (!question) { - return null; - } - - const answers = poll.answers - .map((answer) => ({ - id: answer.id, - text: getTextContent(answer).trim(), - })) - .filter((answer) => answer.id.trim().length > 0 && answer.text.length > 0); - if (answers.length === 0) { - return null; - } - - const maxSelectionsRaw = poll.max_selections; - const maxSelections = - typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw) - ? Math.floor(maxSelectionsRaw) - : 1; - - return { - question, - answers, - kind: poll.kind ?? "m.poll.disclosed", - maxSelections: Math.min(Math.max(maxSelections, 1), answers.length), - }; -} - -export function parsePollStartContent(content: PollStartContent): PollSummary | null { - const parsed = parsePollStart(content); - if (!parsed) { - return null; - } - - return { - eventId: "", - roomId: "", - sender: "", - senderName: "", - question: parsed.question, - answers: parsed.answers.map((answer) => answer.text), - kind: parsed.kind, - maxSelections: parsed.maxSelections, - }; -} - -export function formatPollAsText(summary: PollSummary): string { - const lines = [ - "[Poll]", - summary.question, - "", - ...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`), - ]; - return lines.join("\n"); -} - -function buildTextContent(body: string): TextContent { - return { - "m.text": body, - "org.matrix.msc1767.text": body, - }; -} - -function buildPollFallbackText(question: string, answers: string[]): string { - if (answers.length === 0) { - return question; - } - return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`; -} - -export function buildPollStartContent(poll: PollInput): PollStartContent { - const normalized = normalizePollInput(poll); - const answers = normalized.options.map((option, idx) => ({ - id: `answer${idx + 1}`, - ...buildTextContent(option), - })); - - const isMultiple = normalized.maxSelections > 1; - const fallbackText = buildPollFallbackText( - normalized.question, - answers.map((answer) => getTextContent(answer)), - ); - - return { - [M_POLL_START]: { - question: buildTextContent(normalized.question), - kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed", - max_selections: normalized.maxSelections, - answers, - }, - "m.text": fallbackText, - "org.matrix.msc1767.text": fallbackText, - }; -} - -export function buildPollResponseContent( - pollEventId: string, - answerIds: string[], -): PollResponseContent { - return { - [M_POLL_RESPONSE]: { - answers: answerIds, - }, - [ORG_POLL_RESPONSE]: { - answers: answerIds, - }, - "m.relates_to": { - rel_type: "m.reference", - event_id: pollEventId, - }, - }; -} diff --git a/extensions/matrix-js/src/matrix/probe.ts b/extensions/matrix-js/src/matrix/probe.ts deleted file mode 100644 index 91261f95118..00000000000 --- a/extensions/matrix-js/src/matrix/probe.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { BaseProbeResult } from "openclaw/plugin-sdk/matrix-js"; -import { createMatrixClient, isBunRuntime } from "./client.js"; - -export type MatrixProbe = BaseProbeResult & { - status?: number | null; - elapsedMs: number; - userId?: string | null; -}; - -export async function probeMatrix(params: { - homeserver: string; - accessToken: string; - userId?: string; - timeoutMs: number; -}): Promise { - const started = Date.now(); - const result: MatrixProbe = { - ok: false, - status: null, - error: null, - elapsedMs: 0, - }; - if (isBunRuntime()) { - return { - ...result, - error: "Matrix probe requires Node (bun runtime not supported)", - elapsedMs: Date.now() - started, - }; - } - if (!params.homeserver?.trim()) { - return { - ...result, - error: "missing homeserver", - elapsedMs: Date.now() - started, - }; - } - if (!params.accessToken?.trim()) { - return { - ...result, - error: "missing access token", - elapsedMs: Date.now() - started, - }; - } - try { - const inputUserId = params.userId?.trim() || undefined; - const client = await createMatrixClient({ - homeserver: params.homeserver, - userId: inputUserId, - accessToken: params.accessToken, - localTimeoutMs: params.timeoutMs, - }); - // The client wrapper resolves user ID via whoami when needed. - const userId = await client.getUserId(); - result.ok = true; - result.userId = userId ?? null; - - result.elapsedMs = Date.now() - started; - return result; - } catch (err) { - return { - ...result, - status: - typeof err === "object" && err && "statusCode" in err - ? Number((err as { statusCode?: number }).statusCode) - : result.status, - error: err instanceof Error ? err.message : String(err), - elapsedMs: Date.now() - started, - }; - } -} diff --git a/extensions/matrix-js/src/matrix/send.test.ts b/extensions/matrix-js/src/matrix/send.test.ts deleted file mode 100644 index b17e32b68a7..00000000000 --- a/extensions/matrix-js/src/matrix/send.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { setMatrixRuntime } from "../runtime.js"; - -const loadWebMediaMock = vi.fn().mockResolvedValue({ - buffer: Buffer.from("media"), - fileName: "photo.png", - contentType: "image/png", - kind: "image", -}); -const getImageMetadataMock = vi.fn().mockResolvedValue(null); -const resizeToJpegMock = vi.fn(); - -const runtimeStub = { - config: { - loadConfig: () => ({}), - }, - media: { - loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), - mediaKindFromMime: () => "image", - isVoiceCompatibleAudio: () => false, - getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), - resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), - }, - channel: { - text: { - resolveTextChunkLimit: () => 4000, - resolveChunkMode: () => "length", - chunkMarkdownText: (text: string) => (text ? [text] : []), - chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []), - resolveMarkdownTableMode: () => "code", - convertMarkdownTables: (text: string) => text, - }, - }, -} as unknown as PluginRuntime; - -let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; -let voteMatrixPoll: typeof import("./actions/polls.js").voteMatrixPoll; - -const makeClient = () => { - const sendMessage = vi.fn().mockResolvedValue("evt1"); - const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote"); - const getEvent = vi.fn(); - const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); - const client = { - sendMessage, - sendEvent, - getEvent, - uploadContent, - getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - } as unknown as import("./sdk.js").MatrixClient; - return { client, sendMessage, sendEvent, getEvent, uploadContent }; -}; - -describe("sendMessageMatrix media", () => { - beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ sendMessageMatrix } = await import("./send.js")); - ({ voteMatrixPoll } = await import("./actions/polls.js")); - }); - - beforeEach(() => { - loadWebMediaMock.mockReset().mockResolvedValue({ - buffer: Buffer.from("media"), - fileName: "photo.png", - contentType: "image/png", - kind: "image", - }); - getImageMetadataMock.mockReset().mockResolvedValue(null); - resizeToJpegMock.mockReset(); - setMatrixRuntime(runtimeStub); - }); - - it("uploads media with url payloads", async () => { - const { client, sendMessage, uploadContent } = makeClient(); - - await sendMessageMatrix("room:!room:example", "caption", { - client, - mediaUrl: "file:///tmp/photo.png", - }); - - const uploadArg = uploadContent.mock.calls[0]?.[0]; - expect(Buffer.isBuffer(uploadArg)).toBe(true); - - const content = sendMessage.mock.calls[0]?.[1] as { - url?: string; - msgtype?: string; - format?: string; - formatted_body?: string; - }; - expect(content.msgtype).toBe("m.image"); - expect(content.format).toBe("org.matrix.custom.html"); - expect(content.formatted_body).toContain("caption"); - expect(content.url).toBe("mxc://example/file"); - }); - - it("uploads encrypted media with file payloads", async () => { - const { client, sendMessage, uploadContent } = makeClient(); - (client as { crypto?: object }).crypto = { - isRoomEncrypted: vi.fn().mockResolvedValue(true), - encryptMedia: vi.fn().mockResolvedValue({ - buffer: Buffer.from("encrypted"), - file: { - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }, - }), - }; - - await sendMessageMatrix("room:!room:example", "caption", { - client, - mediaUrl: "file:///tmp/photo.png", - }); - - const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined; - expect(uploadArg?.toString()).toBe("encrypted"); - - const content = sendMessage.mock.calls[0]?.[1] as { - url?: string; - file?: { url?: string }; - }; - expect(content.url).toBeUndefined(); - expect(content.file?.url).toBe("mxc://example/file"); - }); - - it("does not upload plaintext thumbnails for encrypted image sends", async () => { - const { client, uploadContent } = makeClient(); - (client as { crypto?: object }).crypto = { - isRoomEncrypted: vi.fn().mockResolvedValue(true), - encryptMedia: vi.fn().mockResolvedValue({ - buffer: Buffer.from("encrypted"), - file: { - key: { - kty: "oct", - key_ops: ["encrypt", "decrypt"], - alg: "A256CTR", - k: "secret", - ext: true, - }, - iv: "iv", - hashes: { sha256: "hash" }, - v: "v2", - }, - }), - }; - getImageMetadataMock - .mockResolvedValueOnce({ width: 1600, height: 1200 }) - .mockResolvedValueOnce({ width: 800, height: 600 }); - resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb")); - - await sendMessageMatrix("room:!room:example", "caption", { - client, - mediaUrl: "file:///tmp/photo.png", - }); - - expect(uploadContent).toHaveBeenCalledTimes(1); - }); - - it("uploads thumbnail metadata for unencrypted large images", async () => { - const { client, sendMessage, uploadContent } = makeClient(); - getImageMetadataMock - .mockResolvedValueOnce({ width: 1600, height: 1200 }) - .mockResolvedValueOnce({ width: 800, height: 600 }); - resizeToJpegMock.mockResolvedValueOnce(Buffer.from("thumb")); - - await sendMessageMatrix("room:!room:example", "caption", { - client, - mediaUrl: "file:///tmp/photo.png", - }); - - expect(uploadContent).toHaveBeenCalledTimes(2); - const content = sendMessage.mock.calls[0]?.[1] as { - info?: { - thumbnail_url?: string; - thumbnail_info?: { - w?: number; - h?: number; - mimetype?: string; - size?: number; - }; - }; - }; - expect(content.info?.thumbnail_url).toBe("mxc://example/file"); - expect(content.info?.thumbnail_info).toMatchObject({ - w: 800, - h: 600, - mimetype: "image/jpeg", - size: Buffer.from("thumb").byteLength, - }); - }); -}); - -describe("sendMessageMatrix threads", () => { - beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ sendMessageMatrix } = await import("./send.js")); - ({ voteMatrixPoll } = await import("./actions/polls.js")); - }); - - beforeEach(() => { - vi.clearAllMocks(); - setMatrixRuntime(runtimeStub); - }); - - it("includes thread relation metadata when threadId is set", async () => { - const { client, sendMessage } = makeClient(); - - await sendMessageMatrix("room:!room:example", "hello thread", { - client, - threadId: "$thread", - }); - - const content = sendMessage.mock.calls[0]?.[1] as { - "m.relates_to"?: { - rel_type?: string; - event_id?: string; - "m.in_reply_to"?: { event_id?: string }; - }; - }; - - expect(content["m.relates_to"]).toMatchObject({ - rel_type: "m.thread", - event_id: "$thread", - "m.in_reply_to": { event_id: "$thread" }, - }); - }); -}); - -describe("voteMatrixPoll", () => { - beforeAll(async () => { - setMatrixRuntime(runtimeStub); - ({ voteMatrixPoll } = await import("./actions/polls.js")); - }); - - beforeEach(() => { - vi.clearAllMocks(); - setMatrixRuntime(runtimeStub); - }); - - it("maps 1-based option indexes to Matrix poll answer ids", async () => { - const { client, getEvent, sendEvent } = makeClient(); - getEvent.mockResolvedValue({ - type: "m.poll.start", - content: { - "m.poll.start": { - question: { "m.text": "Lunch?" }, - max_selections: 1, - answers: [ - { id: "a1", "m.text": "Pizza" }, - { id: "a2", "m.text": "Sushi" }, - ], - }, - }, - }); - - const result = await voteMatrixPoll("room:!room:example", "$poll", { - client, - optionIndex: 2, - }); - - expect(sendEvent).toHaveBeenCalledWith("!room:example", "m.poll.response", { - "m.poll.response": { answers: ["a2"] }, - "org.matrix.msc3381.poll.response": { answers: ["a2"] }, - "m.relates_to": { - rel_type: "m.reference", - event_id: "$poll", - }, - }); - expect(result).toMatchObject({ - eventId: "evt-poll-vote", - roomId: "!room:example", - pollId: "$poll", - answerIds: ["a2"], - labels: ["Sushi"], - }); - }); - - it("rejects out-of-range option indexes", async () => { - const { client, getEvent } = makeClient(); - getEvent.mockResolvedValue({ - type: "m.poll.start", - content: { - "m.poll.start": { - question: { "m.text": "Lunch?" }, - max_selections: 1, - answers: [{ id: "a1", "m.text": "Pizza" }], - }, - }, - }); - - await expect( - voteMatrixPoll("room:!room:example", "$poll", { - client, - optionIndex: 2, - }), - ).rejects.toThrow("out of range"); - }); - - it("rejects votes that exceed the poll selection cap", async () => { - const { client, getEvent } = makeClient(); - getEvent.mockResolvedValue({ - type: "m.poll.start", - content: { - "m.poll.start": { - question: { "m.text": "Lunch?" }, - max_selections: 1, - answers: [ - { id: "a1", "m.text": "Pizza" }, - { id: "a2", "m.text": "Sushi" }, - ], - }, - }, - }); - - await expect( - voteMatrixPoll("room:!room:example", "$poll", { - client, - optionIndexes: [1, 2], - }), - ).rejects.toThrow("at most 1 selection"); - }); - - it("rejects non-poll events before sending a response", async () => { - const { client, getEvent, sendEvent } = makeClient(); - getEvent.mockResolvedValue({ - type: "m.room.message", - content: { body: "hello" }, - }); - - await expect( - voteMatrixPoll("room:!room:example", "$poll", { - client, - optionIndex: 1, - }), - ).rejects.toThrow("is not a Matrix poll start event"); - expect(sendEvent).not.toHaveBeenCalled(); - }); -}); diff --git a/extensions/matrix-js/src/matrix/send.ts b/extensions/matrix-js/src/matrix/send.ts deleted file mode 100644 index 3ed0d9cb4a7..00000000000 --- a/extensions/matrix-js/src/matrix/send.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { PollInput } from "openclaw/plugin-sdk/matrix-js"; -import { getMatrixRuntime } from "../runtime.js"; -import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; -import { buildMatrixReactionContent } from "./reaction-common.js"; -import type { MatrixClient } from "./sdk.js"; -import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; -import { - buildReplyRelation, - buildTextContent, - buildThreadRelation, - resolveMatrixMsgType, - resolveMatrixVoiceDecision, -} from "./send/formatting.js"; -import { - buildMediaContent, - prepareImageInfo, - resolveMediaDurationMs, - uploadMediaMaybeEncrypted, -} from "./send/media.js"; -import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; -import { - EventType, - MsgType, - type MatrixOutboundContent, - type MatrixSendOpts, - type MatrixSendResult, -} from "./send/types.js"; - -const MATRIX_TEXT_LIMIT = 4000; -const getCore = () => getMatrixRuntime(); - -export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; -export { resolveMatrixRoomId } from "./send/targets.js"; - -type MatrixClientResolveOpts = { - client?: MatrixClient; - timeoutMs?: number; - accountId?: string | null; -}; - -function normalizeMatrixClientResolveOpts( - opts?: MatrixClient | MatrixClientResolveOpts, -): MatrixClientResolveOpts { - if (!opts) { - return {}; - } - if (typeof (opts as MatrixClient).sendEvent === "function") { - return { client: opts as MatrixClient }; - } - return opts; -} - -export async function sendMessageMatrix( - to: string, - message: string, - opts: MatrixSendOpts = {}, -): Promise { - const trimmedMessage = message?.trim() ?? ""; - if (!trimmedMessage && !opts.mediaUrl) { - throw new Error("Matrix send requires text or media"); - } - const { client, stopOnDone } = await resolveMatrixClient({ - client: opts.client, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - }); - try { - const roomId = await resolveMatrixRoomId(client, to); - const cfg = getCore().config.loadConfig(); - const tableMode = getCore().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix-js", - accountId: opts.accountId, - }); - const convertedMessage = getCore().channel.text.convertMarkdownTables( - trimmedMessage, - tableMode, - ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix-js"); - const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix-js", opts.accountId); - const chunks = getCore().channel.text.chunkMarkdownTextWithMode( - convertedMessage, - chunkLimit, - chunkMode, - ); - const threadId = normalizeThreadId(opts.threadId); - const relation = threadId - ? buildThreadRelation(threadId, opts.replyToId) - : buildReplyRelation(opts.replyToId); - const sendContent = async (content: MatrixOutboundContent) => { - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; - - let lastMessageId = ""; - if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { - contentType: media.contentType, - filename: media.fileName, - }); - const durationMs = await resolveMediaDurationMs({ - buffer: media.buffer, - contentType: media.contentType, - fileName: media.fileName, - kind: media.kind, - }); - const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); - const { useVoice } = resolveMatrixVoiceDecision({ - wantsVoice: opts.audioAsVoice === true, - contentType: media.contentType, - fileName: media.fileName, - }); - const msgtype = useVoice ? MsgType.Audio : baseMsgType; - const isImage = msgtype === MsgType.Image; - const imageInfo = isImage - ? await prepareImageInfo({ - buffer: media.buffer, - client, - encrypted: Boolean(uploaded.file), - }) - : undefined; - const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); - const content = buildMediaContent({ - msgtype, - body, - url: uploaded.url, - file: uploaded.file, - filename: media.fileName, - mimetype: media.contentType, - size: media.buffer.byteLength, - durationMs, - relation, - isVoice: useVoice, - imageInfo, - }); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - const textChunks = useVoice ? chunks : rest; - const followupRelation = threadId ? relation : undefined; - for (const chunk of textChunks) { - const text = chunk.trim(); - if (!text) { - continue; - } - const followup = buildTextContent(text, followupRelation); - const followupEventId = await sendContent(followup); - lastMessageId = followupEventId ?? lastMessageId; - } - } else { - for (const chunk of chunks.length ? chunks : [""]) { - const text = chunk.trim(); - if (!text) { - continue; - } - const content = buildTextContent(text, relation); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - } - } - - return { - messageId: lastMessageId || "unknown", - roomId, - }; - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function sendPollMatrix( - to: string, - poll: PollInput, - opts: MatrixSendOpts = {}, -): Promise<{ eventId: string; roomId: string }> { - if (!poll.question?.trim()) { - throw new Error("Matrix poll requires a question"); - } - if (!poll.options?.length) { - throw new Error("Matrix poll requires options"); - } - const { client, stopOnDone } = await resolveMatrixClient({ - client: opts.client, - timeoutMs: opts.timeoutMs, - accountId: opts.accountId, - }); - - try { - const roomId = await resolveMatrixRoomId(client, to); - const pollContent = buildPollStartContent(poll); - const threadId = normalizeThreadId(opts.threadId); - const pollPayload = threadId - ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } - : pollContent; - const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); - - return { - eventId: eventId ?? "unknown", - roomId, - }; - } finally { - if (stopOnDone) { - client.stop(); - } - } -} - -export async function sendTypingMatrix( - roomId: string, - typing: boolean, - timeoutMs?: number, - client?: MatrixClient, -): Promise { - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, - timeoutMs, - }); - try { - const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; - await resolved.setTyping(roomId, typing, resolvedTimeoutMs); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } -} - -export async function sendReadReceiptMatrix( - roomId: string, - eventId: string, - client?: MatrixClient, -): Promise { - if (!eventId?.trim()) { - return; - } - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, - }); - try { - const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); - await resolved.sendReadReceipt(resolvedRoom, eventId.trim()); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } -} - -export async function reactMatrixMessage( - roomId: string, - messageId: string, - emoji: string, - opts?: MatrixClient | MatrixClientResolveOpts, -): Promise { - const clientOpts = normalizeMatrixClientResolveOpts(opts); - const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client: clientOpts.client, - timeoutMs: clientOpts.timeoutMs, - accountId: clientOpts.accountId ?? undefined, - }); - try { - const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); - const reaction = buildMatrixReactionContent(messageId, emoji); - await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); - } finally { - if (stopOnDone) { - resolved.stop(); - } - } -} diff --git a/extensions/matrix-js/src/matrix/send/client.ts b/extensions/matrix-js/src/matrix/send/client.ts deleted file mode 100644 index 75ff3204846..00000000000 --- a/extensions/matrix-js/src/matrix/send/client.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getMatrixRuntime } from "../../runtime.js"; -import type { CoreConfig } from "../../types.js"; -import { resolveMatrixAccountConfig } from "../accounts.js"; -import { getActiveMatrixClient } from "../active-client.js"; -import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; -import type { MatrixClient } from "../sdk.js"; - -const getCore = () => getMatrixRuntime(); - -export function ensureNodeRuntime() { - if (isBunRuntime()) { - throw new Error("Matrix support requires Node (bun runtime not supported)"); - } -} - -export function resolveMediaMaxBytes(accountId?: string | null): number | undefined { - const cfg = getCore().config.loadConfig() as CoreConfig; - const matrixCfg = resolveMatrixAccountConfig({ cfg, accountId }); - const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined; - if (typeof mediaMaxMb === "number") { - return mediaMaxMb * 1024 * 1024; - } - return undefined; -} - -export async function resolveMatrixClient(opts: { - client?: MatrixClient; - timeoutMs?: number; - accountId?: string | null; -}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { - ensureNodeRuntime(); - if (opts.client) { - return { client: opts.client, stopOnDone: false }; - } - const active = getActiveMatrixClient(opts.accountId); - if (active) { - return { client: active, stopOnDone: false }; - } - const auth = await resolveMatrixAuth({ accountId: opts.accountId }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - password: auth.password, - deviceId: auth.deviceId, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, - accountId: opts.accountId, - autoBootstrapCrypto: false, - }); - await client.prepareForOneOff(); - return { client, stopOnDone: true }; -} diff --git a/extensions/matrix-js/src/matrix/send/formatting.ts b/extensions/matrix-js/src/matrix/send/formatting.ts deleted file mode 100644 index bf0ed1989be..00000000000 --- a/extensions/matrix-js/src/matrix/send/formatting.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { getMatrixRuntime } from "../../runtime.js"; -import { markdownToMatrixHtml } from "../format.js"; -import { - MsgType, - RelationType, - type MatrixFormattedContent, - type MatrixMediaMsgType, - type MatrixRelation, - type MatrixReplyRelation, - type MatrixTextContent, - type MatrixThreadRelation, -} from "./types.js"; - -const getCore = () => getMatrixRuntime(); - -export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent { - const content: MatrixTextContent = relation - ? { - msgtype: MsgType.Text, - body, - "m.relates_to": relation, - } - : { - msgtype: MsgType.Text, - body, - }; - applyMatrixFormatting(content, body); - return content; -} - -export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { - const formatted = markdownToMatrixHtml(body ?? ""); - if (!formatted) { - return; - } - content.format = "org.matrix.custom.html"; - content.formatted_body = formatted; -} - -export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { - const trimmed = replyToId?.trim(); - if (!trimmed) { - return undefined; - } - return { "m.in_reply_to": { event_id: trimmed } }; -} - -export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation { - const trimmed = threadId.trim(); - return { - rel_type: RelationType.Thread, - event_id: trimmed, - is_falling_back: true, - "m.in_reply_to": { event_id: replyToId?.trim() || trimmed }, - }; -} - -export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType { - const kind = getCore().media.mediaKindFromMime(contentType ?? ""); - switch (kind) { - case "image": - return MsgType.Image; - case "audio": - return MsgType.Audio; - case "video": - return MsgType.Video; - default: - return MsgType.File; - } -} - -export function resolveMatrixVoiceDecision(opts: { - wantsVoice: boolean; - contentType?: string; - fileName?: string; -}): { useVoice: boolean } { - if (!opts.wantsVoice) { - return { useVoice: false }; - } - if (isMatrixVoiceCompatibleAudio(opts)) { - return { useVoice: true }; - } - return { useVoice: false }; -} - -function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean { - // Matrix currently shares the core voice compatibility policy. - // Keep this wrapper as the seam if Matrix policy diverges later. - return getCore().media.isVoiceCompatibleAudio({ - contentType: opts.contentType, - fileName: opts.fileName, - }); -} diff --git a/extensions/matrix-js/src/matrix/send/media.ts b/extensions/matrix-js/src/matrix/send/media.ts deleted file mode 100644 index 03d5d98d324..00000000000 --- a/extensions/matrix-js/src/matrix/send/media.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { parseBuffer, type IFileInfo } from "music-metadata"; -import { getMatrixRuntime } from "../../runtime.js"; -import type { - DimensionalFileInfo, - EncryptedFile, - FileWithThumbnailInfo, - MatrixClient, - TimedFileInfo, - VideoFileInfo, -} from "../sdk.js"; -import { applyMatrixFormatting } from "./formatting.js"; -import { - type MatrixMediaContent, - type MatrixMediaInfo, - type MatrixMediaMsgType, - type MatrixRelation, - type MediaKind, -} from "./types.js"; - -const getCore = () => getMatrixRuntime(); - -export function buildMatrixMediaInfo(params: { - size: number; - mimetype?: string; - durationMs?: number; - imageInfo?: DimensionalFileInfo; -}): MatrixMediaInfo | undefined { - const base: FileWithThumbnailInfo = {}; - if (Number.isFinite(params.size)) { - base.size = params.size; - } - if (params.mimetype) { - base.mimetype = params.mimetype; - } - if (params.imageInfo) { - const dimensional: DimensionalFileInfo = { - ...base, - ...params.imageInfo, - }; - if (typeof params.durationMs === "number") { - const videoInfo: VideoFileInfo = { - ...dimensional, - duration: params.durationMs, - }; - return videoInfo; - } - return dimensional; - } - if (typeof params.durationMs === "number") { - const timedInfo: TimedFileInfo = { - ...base, - duration: params.durationMs, - }; - return timedInfo; - } - if (Object.keys(base).length === 0) { - return undefined; - } - return base; -} - -export function buildMediaContent(params: { - msgtype: MatrixMediaMsgType; - body: string; - url?: string; - filename?: string; - mimetype?: string; - size: number; - relation?: MatrixRelation; - isVoice?: boolean; - durationMs?: number; - imageInfo?: DimensionalFileInfo; - file?: EncryptedFile; -}): MatrixMediaContent { - const info = buildMatrixMediaInfo({ - size: params.size, - mimetype: params.mimetype, - durationMs: params.durationMs, - imageInfo: params.imageInfo, - }); - const base: MatrixMediaContent = { - msgtype: params.msgtype, - body: params.body, - filename: params.filename, - info: info ?? undefined, - }; - // Encrypted media should only include the "file" payload, not top-level "url". - if (!params.file && params.url) { - base.url = params.url; - } - // For encrypted files, add the file object - if (params.file) { - base.file = params.file; - } - if (params.isVoice) { - base["org.matrix.msc3245.voice"] = {}; - if (typeof params.durationMs === "number") { - base["org.matrix.msc1767.audio"] = { - duration: params.durationMs, - }; - } - } - if (params.relation) { - base["m.relates_to"] = params.relation; - } - applyMatrixFormatting(base, params.body); - return base; -} - -const THUMBNAIL_MAX_SIDE = 800; -const THUMBNAIL_QUALITY = 80; - -export async function prepareImageInfo(params: { - buffer: Buffer; - client: MatrixClient; - encrypted?: boolean; -}): Promise { - const meta = await getCore() - .media.getImageMetadata(params.buffer) - .catch(() => null); - if (!meta) { - return undefined; - } - const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; - if (params.encrypted) { - // For E2EE media, avoid uploading plaintext thumbnails. - return imageInfo; - } - const maxDim = Math.max(meta.width, meta.height); - if (maxDim > THUMBNAIL_MAX_SIDE) { - try { - const thumbBuffer = await getCore().media.resizeToJpeg({ - buffer: params.buffer, - maxSide: THUMBNAIL_MAX_SIDE, - quality: THUMBNAIL_QUALITY, - withoutEnlargement: true, - }); - const thumbMeta = await getCore() - .media.getImageMetadata(thumbBuffer) - .catch(() => null); - const thumbUri = await params.client.uploadContent( - thumbBuffer, - "image/jpeg", - "thumbnail.jpg", - ); - imageInfo.thumbnail_url = thumbUri; - if (thumbMeta) { - imageInfo.thumbnail_info = { - w: thumbMeta.width, - h: thumbMeta.height, - mimetype: "image/jpeg", - size: thumbBuffer.byteLength, - }; - } - } catch { - // Thumbnail generation failed, continue without it - } - } - return imageInfo; -} - -export async function resolveMediaDurationMs(params: { - buffer: Buffer; - contentType?: string; - fileName?: string; - kind: MediaKind; -}): Promise { - if (params.kind !== "audio" && params.kind !== "video") { - return undefined; - } - try { - const fileInfo: IFileInfo | string | undefined = - params.contentType || params.fileName - ? { - mimeType: params.contentType, - size: params.buffer.byteLength, - path: params.fileName, - } - : undefined; - const metadata = await parseBuffer(params.buffer, fileInfo, { - duration: true, - skipCovers: true, - }); - const durationSeconds = metadata.format.duration; - if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) { - return Math.max(0, Math.round(durationSeconds * 1000)); - } - } catch { - // Duration is optional; ignore parse failures. - } - return undefined; -} - -async function uploadFile( - client: MatrixClient, - file: Buffer, - params: { - contentType?: string; - filename?: string; - }, -): Promise { - return await client.uploadContent(file, params.contentType, params.filename); -} - -/** - * Upload media with optional encryption for E2EE rooms. - */ -export async function uploadMediaMaybeEncrypted( - client: MatrixClient, - roomId: string, - buffer: Buffer, - params: { - contentType?: string; - filename?: string; - }, -): Promise<{ url: string; file?: EncryptedFile }> { - // Check if room is encrypted and crypto is available - const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId)); - - if (isEncrypted && client.crypto) { - // Encrypt the media before uploading - const encrypted = await client.crypto.encryptMedia(buffer); - const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename); - const file: EncryptedFile = { url: mxc, ...encrypted.file }; - return { - url: mxc, - file, - }; - } - - // Upload unencrypted - const mxc = await uploadFile(client, buffer, params); - return { url: mxc }; -} diff --git a/extensions/matrix-js/src/matrix/send/targets.test.ts b/extensions/matrix-js/src/matrix/send/targets.test.ts deleted file mode 100644 index 3e3610cd300..00000000000 --- a/extensions/matrix-js/src/matrix/send/targets.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "../sdk.js"; -import { EventType } from "./types.js"; - -let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; -let normalizeThreadId: typeof import("./targets.js").normalizeThreadId; - -beforeEach(async () => { - vi.resetModules(); - ({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js")); -}); - -describe("resolveMatrixRoomId", () => { - it("uses m.direct when available", async () => { - const userId = "@user:example.org"; - const client = { - getAccountData: vi.fn().mockResolvedValue({ - [userId]: ["!room:example.org"], - }), - getJoinedRooms: vi.fn(), - getJoinedRoomMembers: vi.fn(), - setAccountData: vi.fn(), - } as unknown as MatrixClient; - - const roomId = await resolveMatrixRoomId(client, userId); - - expect(roomId).toBe("!room:example.org"); - // oxlint-disable-next-line typescript/unbound-method - expect(client.getJoinedRooms).not.toHaveBeenCalled(); - // oxlint-disable-next-line typescript/unbound-method - expect(client.setAccountData).not.toHaveBeenCalled(); - }); - - it("falls back to joined rooms and persists m.direct", async () => { - const userId = "@fallback:example.org"; - const roomId = "!room:example.org"; - const setAccountData = vi.fn().mockResolvedValue(undefined); - const client = { - getAccountData: vi.fn().mockRejectedValue(new Error("nope")), - getJoinedRooms: vi.fn().mockResolvedValue([roomId]), - getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]), - setAccountData, - } as unknown as MatrixClient; - - const resolved = await resolveMatrixRoomId(client, userId); - - expect(resolved).toBe(roomId); - expect(setAccountData).toHaveBeenCalledWith( - EventType.Direct, - expect.objectContaining({ [userId]: [roomId] }), - ); - }); - - it("continues when a room member lookup fails", async () => { - const userId = "@continue:example.org"; - const roomId = "!good:example.org"; - const setAccountData = vi.fn().mockResolvedValue(undefined); - const getJoinedRoomMembers = vi - .fn() - .mockRejectedValueOnce(new Error("boom")) - .mockResolvedValueOnce(["@bot:example.org", userId]); - const client = { - getAccountData: vi.fn().mockRejectedValue(new Error("nope")), - getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]), - getJoinedRoomMembers, - setAccountData, - } as unknown as MatrixClient; - - const resolved = await resolveMatrixRoomId(client, userId); - - expect(resolved).toBe(roomId); - expect(setAccountData).toHaveBeenCalled(); - }); - - it("allows larger rooms when no 1:1 match exists", async () => { - const userId = "@group:example.org"; - const roomId = "!group:example.org"; - const client = { - getAccountData: vi.fn().mockRejectedValue(new Error("nope")), - getJoinedRooms: vi.fn().mockResolvedValue([roomId]), - getJoinedRoomMembers: vi - .fn() - .mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]), - setAccountData: vi.fn().mockResolvedValue(undefined), - } as unknown as MatrixClient; - - const resolved = await resolveMatrixRoomId(client, userId); - - expect(resolved).toBe(roomId); - }); -}); - -describe("normalizeThreadId", () => { - it("returns null for empty thread ids", () => { - expect(normalizeThreadId(" ")).toBeNull(); - expect(normalizeThreadId("$thread")).toBe("$thread"); - }); -}); diff --git a/extensions/matrix-js/src/matrix/send/targets.ts b/extensions/matrix-js/src/matrix/send/targets.ts deleted file mode 100644 index 8d358ecf825..00000000000 --- a/extensions/matrix-js/src/matrix/send/targets.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { MatrixClient } from "../sdk.js"; -import { EventType, type MatrixDirectAccountData } from "./types.js"; - -function normalizeTarget(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error("Matrix target is required (room: or #alias)"); - } - return trimmed; -} - -export function normalizeThreadId(raw?: string | number | null): string | null { - if (raw === undefined || raw === null) { - return null; - } - const trimmed = String(raw).trim(); - return trimmed ? trimmed : null; -} - -// Size-capped to prevent unbounded growth (#4948) -const MAX_DIRECT_ROOM_CACHE_SIZE = 1024; -const directRoomCache = new Map(); -function setDirectRoomCached(key: string, value: string): void { - directRoomCache.set(key, value); - if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) { - const oldest = directRoomCache.keys().next().value; - if (oldest !== undefined) { - directRoomCache.delete(oldest); - } - } -} - -async function persistDirectRoom( - client: MatrixClient, - userId: string, - roomId: string, -): Promise { - let directContent: MatrixDirectAccountData | undefined; - try { - directContent = (await client.getAccountData(EventType.Direct)) as - | MatrixDirectAccountData - | undefined; - } catch { - // Ignore fetch errors and fall back to an empty map. - } - const existing = directContent && !Array.isArray(directContent) ? directContent : {}; - const current = Array.isArray(existing[userId]) ? existing[userId] : []; - if (current[0] === roomId) { - return; - } - const next = [roomId, ...current.filter((id) => id !== roomId)]; - try { - await client.setAccountData(EventType.Direct, { - ...existing, - [userId]: next, - }); - } catch { - // Ignore persistence errors. - } -} - -async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { - const trimmed = userId.trim(); - if (!trimmed.startsWith("@")) { - throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); - } - - const cached = directRoomCache.get(trimmed); - if (cached) { - return cached; - } - - // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot). - try { - const directContent = (await client.getAccountData(EventType.Direct)) as Record< - string, - string[] | undefined - >; - const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; - if (list && list.length > 0) { - setDirectRoomCached(trimmed, list[0]); - return list[0]; - } - } catch { - // Ignore and fall back. - } - - // 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. - // Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. - let fallbackRoom: string | null = null; - try { - const rooms = await client.getJoinedRooms(); - for (const roomId of rooms) { - let members: string[]; - try { - members = await client.getJoinedRoomMembers(roomId); - } catch { - continue; - } - if (!members.includes(trimmed)) { - continue; - } - // Prefer classic 1:1 rooms, but allow larger rooms if requested. - if (members.length === 2) { - setDirectRoomCached(trimmed, roomId); - await persistDirectRoom(client, trimmed, roomId); - return roomId; - } - if (!fallbackRoom) { - fallbackRoom = roomId; - } - } - } catch { - // Ignore and fall back. - } - - if (fallbackRoom) { - setDirectRoomCached(trimmed, fallbackRoom); - await persistDirectRoom(client, trimmed, fallbackRoom); - return fallbackRoom; - } - - throw new Error(`No direct room found for ${trimmed} (m.direct missing)`); -} - -export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise { - const target = normalizeTarget(raw); - const lowered = target.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return await resolveMatrixRoomId(client, target.slice("matrix:".length)); - } - if (lowered.startsWith("room:")) { - return await resolveMatrixRoomId(client, target.slice("room:".length)); - } - if (lowered.startsWith("channel:")) { - return await resolveMatrixRoomId(client, target.slice("channel:".length)); - } - if (lowered.startsWith("user:")) { - return await resolveDirectRoomId(client, target.slice("user:".length)); - } - if (target.startsWith("@")) { - return await resolveDirectRoomId(client, target); - } - if (target.startsWith("#")) { - const resolved = await client.resolveRoom(target); - if (!resolved) { - throw new Error(`Matrix alias ${target} could not be resolved`); - } - return resolved; - } - return target; -} diff --git a/extensions/matrix-js/src/matrix/send/types.ts b/extensions/matrix-js/src/matrix/send/types.ts deleted file mode 100644 index d597255a593..00000000000 --- a/extensions/matrix-js/src/matrix/send/types.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - MATRIX_ANNOTATION_RELATION_TYPE, - MATRIX_REACTION_EVENT_TYPE, - type MatrixReactionEventContent, -} from "../reaction-common.js"; -import type { - DimensionalFileInfo, - EncryptedFile, - FileWithThumbnailInfo, - MessageEventContent, - TextualMessageEventContent, - TimedFileInfo, - VideoFileInfo, -} from "../sdk.js"; - -// Message types -export const MsgType = { - Text: "m.text", - Image: "m.image", - Audio: "m.audio", - Video: "m.video", - File: "m.file", - Notice: "m.notice", -} as const; - -// Relation types -export const RelationType = { - Annotation: MATRIX_ANNOTATION_RELATION_TYPE, - Replace: "m.replace", - Thread: "m.thread", -} as const; - -// Event types -export const EventType = { - Direct: "m.direct", - Reaction: MATRIX_REACTION_EVENT_TYPE, - RoomMessage: "m.room.message", -} as const; - -export type MatrixDirectAccountData = Record; - -export type MatrixReplyRelation = { - "m.in_reply_to": { event_id: string }; -}; - -export type MatrixThreadRelation = { - rel_type: typeof RelationType.Thread; - event_id: string; - is_falling_back?: boolean; - "m.in_reply_to"?: { event_id: string }; -}; - -export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation; - -export type MatrixReplyMeta = { - "m.relates_to"?: MatrixRelation; -}; - -export type MatrixMediaInfo = - | FileWithThumbnailInfo - | DimensionalFileInfo - | TimedFileInfo - | VideoFileInfo; - -export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta; - -export type MatrixMediaContent = MessageEventContent & - MatrixReplyMeta & { - info?: MatrixMediaInfo; - url?: string; - file?: EncryptedFile; - filename?: string; - "org.matrix.msc3245.voice"?: Record; - "org.matrix.msc1767.audio"?: { duration: number }; - }; - -export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; - -export type ReactionEventContent = MatrixReactionEventContent; - -export type MatrixSendResult = { - messageId: string; - roomId: string; -}; - -export type MatrixSendOpts = { - client?: import("../sdk.js").MatrixClient; - mediaUrl?: string; - accountId?: string; - replyToId?: string; - threadId?: string | number | null; - timeoutMs?: number; - /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ - audioAsVoice?: boolean; -}; - -export type MatrixMediaMsgType = - | typeof MsgType.Image - | typeof MsgType.Audio - | typeof MsgType.Video - | typeof MsgType.File; - -export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; - -export type MatrixFormattedContent = MessageEventContent & { - format?: string; - formatted_body?: string; -}; diff --git a/extensions/matrix-js/src/onboarding.ts b/extensions/matrix-js/src/onboarding.ts deleted file mode 100644 index 5684d6c764e..00000000000 --- a/extensions/matrix-js/src/onboarding.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import type { DmPolicy } from "openclaw/plugin-sdk/matrix-js"; -import { - addWildcardAllowFrom, - formatDocsLink, - mergeAllowFromEntries, - normalizeAccountId, - promptAccountId, - promptChannelAccessConfig, - type RuntimeEnv, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/matrix-js"; -import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; -import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; -import { - listMatrixAccountIds, - resolveDefaultMatrixAccountId, - resolveMatrixAccount, - resolveMatrixAccountConfig, -} from "./matrix/accounts.js"; -import { - getMatrixScopedEnvVarNames, - hasReadyMatrixEnvAuth, - resolveScopedMatrixEnvConfig, -} from "./matrix/client.js"; -import { updateMatrixAccountConfig } from "./matrix/config-update.js"; -import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; -import type { CoreConfig } from "./types.js"; - -const channel = "matrix-js" as const; - -function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { - const allowFrom = - policy === "open" - ? addWildcardAllowFrom(cfg.channels?.["matrix-js"]?.dm?.allowFrom) - : undefined; - return { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": { - ...cfg.channels?.["matrix-js"], - dm: { - ...cfg.channels?.["matrix-js"]?.dm, - policy, - ...(allowFrom ? { allowFrom } : {}), - }, - }, - }, - }; -} - -async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { - 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-js", "channels/matrix-js")}`, - ].join("\n"), - "Matrix setup", - ); -} - -async function promptMatrixAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const { cfg, prompter } = params; - const existingAllowFrom = cfg.channels?.["matrix-js"]?.dm?.allowFrom ?? []; - const account = resolveMatrixAccount({ cfg }); - const canResolve = Boolean(account.configured); - - const parseInput = (raw: string) => - raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); - - const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); - - while (true) { - const entry = await prompter.text({ - message: "Matrix allowFrom (full @user:server; display name only if unique)", - placeholder: "@user:server", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - const parts = parseInput(String(entry)); - const resolvedIds: string[] = []; - const pending: string[] = []; - const unresolved: string[] = []; - const unresolvedNotes: string[] = []; - - for (const part of parts) { - if (isFullUserId(part)) { - resolvedIds.push(part); - continue; - } - if (!canResolve) { - unresolved.push(part); - continue; - } - pending.push(part); - } - - if (pending.length > 0) { - const results = await resolveMatrixTargets({ - cfg, - inputs: pending, - kind: "user", - }).catch(() => []); - for (const result of results) { - if (result?.resolved && result.id) { - resolvedIds.push(result.id); - continue; - } - if (result?.input) { - unresolved.push(result.input); - if (result.note) { - unresolvedNotes.push(`${result.input}: ${result.note}`); - } - } - } - } - - if (unresolved.length > 0) { - const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; - await prompter.note( - `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, - "Matrix allowlist", - ); - continue; - } - - const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); - return { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": { - ...cfg.channels?.["matrix-js"], - enabled: true, - dm: { - ...cfg.channels?.["matrix-js"]?.dm, - policy: "allowlist", - allowFrom: unique, - }, - }, - }, - }; - } -} - -function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { - return { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": { - ...cfg.channels?.["matrix-js"], - enabled: true, - groupPolicy, - }, - }, - }; -} - -function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { - const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); - return { - ...cfg, - channels: { - ...cfg.channels, - "matrix-js": { - ...cfg.channels?.["matrix-js"], - enabled: true, - groups, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Matrix", - channel, - policyKey: "channels.matrix-js.dm.policy", - allowFromKey: "channels.matrix-js.dm.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.["matrix-js"]?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), - promptAllowFrom: promptMatrixAllowFrom, -}; - -type MatrixConfigureIntent = "update" | "add-account"; - -async function runMatrixConfigure(params: { - cfg: CoreConfig; - runtime: RuntimeEnv; - prompter: WizardPrompter; - forceAllowFrom: boolean; - accountOverrides?: Partial>; - shouldPromptAccountIds?: boolean; - intent: MatrixConfigureIntent; -}): Promise<{ cfg: CoreConfig; accountId: string }> { - let next = migrateMatrixLegacyCredentialsToDefaultAccount(params.cfg); - await ensureMatrixSdkInstalled({ - runtime: params.runtime, - confirm: async (message) => - await params.prompter.confirm({ - message, - initialValue: true, - }), - }); - const defaultAccountId = resolveDefaultMatrixAccountId(next); - let accountId = defaultAccountId || DEFAULT_ACCOUNT_ID; - if (params.intent === "add-account") { - const enteredName = String( - await params.prompter.text({ - message: "Matrix account name", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - accountId = normalizeAccountId(enteredName); - if (enteredName !== accountId) { - await params.prompter.note(`Account id will be "${accountId}".`, "Matrix account"); - } - next = updateMatrixAccountConfig(next, accountId, { name: enteredName, enabled: true }); - } else { - const override = params.accountOverrides?.[channel]?.trim(); - if (override) { - accountId = normalizeAccountId(override); - } else if (params.shouldPromptAccountIds) { - accountId = await promptAccountId({ - cfg: next, - prompter: params.prompter, - label: "Matrix-js", - currentId: accountId, - listAccountIds: (inputCfg) => listMatrixAccountIds(inputCfg as CoreConfig), - defaultAccountId, - }); - } - } - - const existing = resolveMatrixAccountConfig({ cfg: next, accountId }); - const account = resolveMatrixAccount({ cfg: next, accountId }); - if (!account.configured) { - await noteMatrixAuthHelp(params.prompter); - } - - const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); - const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env); - const globalEnv = { - homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "", - userId: process.env.MATRIX_USER_ID?.trim() ?? "", - accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined, - password: process.env.MATRIX_PASSWORD?.trim() || undefined, - }; - const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); - const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv); - const globalReady = hasReadyMatrixEnvAuth(globalEnv); - const envReady = - scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady)); - const envHomeserver = - scopedEnv.homeserver || - (accountId === DEFAULT_ACCOUNT_ID - ? defaultScopedEnv.homeserver || globalEnv.homeserver - : undefined); - const envUserId = - scopedEnv.userId || - (accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined); - - if ( - envReady && - !existing.homeserver && - !existing.userId && - !existing.accessToken && - !existing.password - ) { - const scopedEnvNames = getMatrixScopedEnvVarNames(accountId); - const envSourceHint = - accountId === DEFAULT_ACCOUNT_ID - ? "MATRIX_* or MATRIX_DEFAULT_*" - : `${scopedEnvNames.homeserver} (+ auth vars)`; - const useEnv = await params.prompter.confirm({ - message: `Matrix env vars detected (${envSourceHint}). Use env values?`, - initialValue: true, - }); - if (useEnv) { - next = updateMatrixAccountConfig(next, accountId, { enabled: true }); - if (params.forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); - } - return { cfg: next, accountId }; - } - } - - const homeserver = String( - await params.prompter.text({ - message: "Matrix homeserver URL", - initialValue: existing.homeserver ?? envHomeserver, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!/^https?:\/\//i.test(raw)) { - return "Use a full URL (https://...)"; - } - return undefined; - }, - }), - ).trim(); - - let accessToken = existing.accessToken ?? ""; - let password = existing.password ?? ""; - let userId = existing.userId ?? ""; - - if (accessToken || password) { - const keep = await params.prompter.confirm({ - message: "Matrix credentials already configured. Keep them?", - initialValue: true, - }); - if (!keep) { - accessToken = ""; - password = ""; - userId = ""; - } - } - - if (!accessToken && !password) { - const authMode = await params.prompter.select({ - message: "Matrix auth method", - options: [ - { value: "token", label: "Access token (user ID fetched automatically)" }, - { value: "password", label: "Password (requires user ID)" }, - ], - }); - - if (authMode === "token") { - accessToken = String( - await params.prompter.text({ - message: "Matrix access token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - userId = ""; - } else { - userId = String( - await params.prompter.text({ - message: "Matrix user ID", - initialValue: existing.userId ?? envUserId, - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - if (!raw.startsWith("@")) { - return "Matrix user IDs should start with @"; - } - if (!raw.includes(":")) { - return "Matrix user IDs should include a server (:server)"; - } - return undefined; - }, - }), - ).trim(); - password = String( - await params.prompter.text({ - message: "Matrix password", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - ).trim(); - } - } - - const deviceName = String( - await params.prompter.text({ - message: "Matrix device name (optional)", - initialValue: existing.deviceName ?? "OpenClaw Gateway", - }), - ).trim(); - - const enableEncryption = await params.prompter.confirm({ - message: "Enable end-to-end encryption (E2EE)?", - initialValue: existing.encryption ?? false, - }); - - next = updateMatrixAccountConfig(next, accountId, { - enabled: true, - homeserver, - userId: userId || null, - accessToken: accessToken || null, - password: password || null, - deviceName: deviceName || null, - encryption: enableEncryption, - }); - - if (params.forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); - } - - const existingGroups = - next.channels?.["matrix-js"]?.groups ?? next.channels?.["matrix-js"]?.rooms; - const accessConfig = await promptChannelAccessConfig({ - prompter: params.prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.["matrix-js"]?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - if (resolvedIds.length > 0 || unresolved.length > 0) { - await params.prompter.note( - [ - resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, - unresolved.length > 0 - ? `Unresolved (kept as typed): ${unresolved.join(", ")}` - : undefined, - ] - .filter(Boolean) - .join("\n"), - "Matrix rooms", - ); - } - } catch (err) { - await params.prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } - } - - return { cfg: next, accountId }; -} - -export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); - return { - channel, - configured, - statusLines: [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ], - selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", - }; - }, - configure: async ({ - cfg, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - }) => - await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: "update", - }), - configureInteractive: async ({ - cfg, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - configured, - }) => { - if (!configured) { - return await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: "update", - }); - } - const action = await prompter.select({ - message: "Matrix-js already configured. What do you want to do?", - options: [ - { value: "update", label: "Modify settings" }, - { value: "add-account", label: "Add account" }, - { value: "skip", label: "Skip (leave as-is)" }, - ], - initialValue: "update", - }); - if (action === "skip") { - return "skip"; - } - return await runMatrixConfigure({ - cfg: cfg as CoreConfig, - runtime, - prompter, - forceAllowFrom, - accountOverrides, - shouldPromptAccountIds, - intent: action === "add-account" ? "add-account" : "update", - }); - }, - dmPolicy, - disable: (cfg) => ({ - ...(cfg as CoreConfig), - channels: { - ...(cfg as CoreConfig).channels, - "matrix-js": { ...(cfg as CoreConfig).channels?.["matrix-js"], enabled: false }, - }, - }), -}; diff --git a/extensions/matrix-js/src/outbound.ts b/extensions/matrix-js/src/outbound.ts deleted file mode 100644 index ce2f6642f32..00000000000 --- a/extensions/matrix-js/src/outbound.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/matrix-js"; -import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; -import { getMatrixRuntime } from "./runtime.js"; - -export const matrixOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - textChunkLimit: 4000, - sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; - const resolvedThreadId = - threadId !== undefined && threadId !== null ? String(threadId) : undefined; - const result = await send(to, text, { - replyToId: replyToId ?? undefined, - threadId: resolvedThreadId, - accountId: accountId ?? undefined, - }); - return { - channel: "matrix-js", - messageId: result.messageId, - roomId: result.roomId, - }; - }, - sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { - const send = deps?.sendMatrix ?? sendMessageMatrix; - const resolvedThreadId = - threadId !== undefined && threadId !== null ? String(threadId) : undefined; - const result = await send(to, text, { - mediaUrl, - replyToId: replyToId ?? undefined, - threadId: resolvedThreadId, - accountId: accountId ?? undefined, - }); - return { - channel: "matrix-js", - messageId: result.messageId, - roomId: result.roomId, - }; - }, - sendPoll: async ({ to, poll, threadId, accountId }) => { - const resolvedThreadId = - threadId !== undefined && threadId !== null ? String(threadId) : undefined; - const result = await sendPollMatrix(to, poll, { - threadId: resolvedThreadId, - accountId: accountId ?? undefined, - }); - return { - channel: "matrix-js", - messageId: result.eventId, - roomId: result.roomId, - pollId: result.eventId, - }; - }, -}; diff --git a/extensions/matrix-js/src/resolve-targets.test.ts b/extensions/matrix-js/src/resolve-targets.test.ts deleted file mode 100644 index cdf94d78b3e..00000000000 --- a/extensions/matrix-js/src/resolve-targets.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix-js"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; -import { resolveMatrixTargets } from "./resolve-targets.js"; - -vi.mock("./directory-live.js", () => ({ - listMatrixDirectoryPeersLive: vi.fn(), - listMatrixDirectoryGroupsLive: vi.fn(), -})); - -describe("resolveMatrixTargets (users)", () => { - beforeEach(() => { - vi.mocked(listMatrixDirectoryPeersLive).mockReset(); - vi.mocked(listMatrixDirectoryGroupsLive).mockReset(); - }); - - it("resolves exact unique display name matches", async () => { - const matches: ChannelDirectoryEntry[] = [ - { kind: "user", id: "@alice:example.org", name: "Alice" }, - ]; - vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); - - expect(result?.resolved).toBe(true); - expect(result?.id).toBe("@alice:example.org"); - }); - - it("does not resolve ambiguous or non-exact matches", async () => { - const matches: ChannelDirectoryEntry[] = [ - { kind: "user", id: "@alice:example.org", name: "Alice" }, - { kind: "user", id: "@alice:evil.example", name: "Alice" }, - ]; - vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); - - expect(result?.resolved).toBe(false); - expect(result?.note).toMatch(/use full Matrix ID/i); - }); - - it("prefers exact group matches over first partial result", async () => { - const matches: ChannelDirectoryEntry[] = [ - { kind: "group", id: "!one:example.org", name: "General", handle: "#general" }, - { kind: "group", id: "!two:example.org", name: "Team", handle: "#team" }, - ]; - vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue(matches); - - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["#team"], - kind: "group", - }); - - expect(result?.resolved).toBe(true); - expect(result?.id).toBe("!two:example.org"); - expect(result?.note).toBe("multiple matches; chose first"); - }); - - it("reuses directory lookups for duplicate inputs", async () => { - vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ - { kind: "user", id: "@alice:example.org", name: "Alice" }, - ]); - vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([ - { kind: "group", id: "!team:example.org", name: "Team", handle: "#team" }, - ]); - - const userResults = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice", "Alice"], - kind: "user", - }); - const groupResults = await resolveMatrixTargets({ - cfg: {}, - inputs: ["#team", "#team"], - kind: "group", - }); - - expect(userResults.every((entry) => entry.resolved)).toBe(true); - expect(groupResults.every((entry) => entry.resolved)).toBe(true); - expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); - expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); - }); -}); diff --git a/extensions/matrix-js/src/resolve-targets.ts b/extensions/matrix-js/src/resolve-targets.ts deleted file mode 100644 index a6dc93059a6..00000000000 --- a/extensions/matrix-js/src/resolve-targets.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { - ChannelDirectoryEntry, - ChannelResolveKind, - ChannelResolveResult, - RuntimeEnv, -} from "openclaw/plugin-sdk/matrix-js"; -import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; - -function findExactDirectoryMatches( - matches: ChannelDirectoryEntry[], - query: string, -): ChannelDirectoryEntry[] { - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return []; - } - return matches.filter((match) => { - const id = match.id.trim().toLowerCase(); - const name = match.name?.trim().toLowerCase(); - const handle = match.handle?.trim().toLowerCase(); - return normalized === id || normalized === name || normalized === handle; - }); -} - -function pickBestGroupMatch( - matches: ChannelDirectoryEntry[], - query: string, -): ChannelDirectoryEntry | undefined { - if (matches.length === 0) { - return undefined; - } - const [exact] = findExactDirectoryMatches(matches, query); - return exact ?? matches[0]; -} - -function pickBestUserMatch( - matches: ChannelDirectoryEntry[], - query: string, -): ChannelDirectoryEntry | undefined { - if (matches.length === 0) { - return undefined; - } - const exact = findExactDirectoryMatches(matches, query); - if (exact.length === 1) { - return exact[0]; - } - return undefined; -} - -function describeUserMatchFailure(matches: ChannelDirectoryEntry[], query: string): string { - if (matches.length === 0) { - return "no matches"; - } - const normalized = query.trim().toLowerCase(); - if (!normalized) { - return "empty input"; - } - const exact = findExactDirectoryMatches(matches, normalized); - if (exact.length === 0) { - return "no exact match; use full Matrix ID"; - } - if (exact.length > 1) { - return "multiple exact matches; use full Matrix ID"; - } - return "no exact match; use full Matrix ID"; -} - -export async function resolveMatrixTargets(params: { - cfg: unknown; - inputs: string[]; - kind: ChannelResolveKind; - runtime?: RuntimeEnv; -}): Promise { - const results: ChannelResolveResult[] = []; - const userLookupCache = new Map(); - const groupLookupCache = new Map(); - - const readUserMatches = async (query: string): Promise => { - const cached = userLookupCache.get(query); - if (cached) { - return cached; - } - const matches = await listMatrixDirectoryPeersLive({ - cfg: params.cfg, - query, - limit: 5, - }); - userLookupCache.set(query, matches); - return matches; - }; - - const readGroupMatches = async (query: string): Promise => { - const cached = groupLookupCache.get(query); - if (cached) { - return cached; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query, - limit: 5, - }); - groupLookupCache.set(query, matches); - return matches; - }; - - for (const input of params.inputs) { - const trimmed = input.trim(); - if (!trimmed) { - results.push({ input, resolved: false, note: "empty input" }); - continue; - } - if (params.kind === "user") { - if (trimmed.startsWith("@") && trimmed.includes(":")) { - results.push({ input, resolved: true, id: trimmed }); - continue; - } - try { - const matches = await readUserMatches(trimmed); - const best = pickBestUserMatch(matches, trimmed); - results.push({ - input, - resolved: Boolean(best?.id), - id: best?.id, - name: best?.name, - note: best ? undefined : describeUserMatchFailure(matches, trimmed), - }); - } catch (err) { - params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); - results.push({ input, resolved: false, note: "lookup failed" }); - } - continue; - } - try { - const matches = await readGroupMatches(trimmed); - const best = pickBestGroupMatch(matches, trimmed); - results.push({ - input, - resolved: Boolean(best?.id), - id: best?.id, - name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, - }); - } catch (err) { - params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); - results.push({ input, resolved: false, note: "lookup failed" }); - } - } - return results; -} diff --git a/extensions/matrix-js/src/runtime.ts b/extensions/matrix-js/src/runtime.ts deleted file mode 100644 index d595c7a1805..00000000000 --- a/extensions/matrix-js/src/runtime.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; - -let runtime: PluginRuntime | null = null; - -export function setMatrixRuntime(next: PluginRuntime) { - runtime = next; -} - -export function getMatrixRuntime(): PluginRuntime { - if (!runtime) { - throw new Error("Matrix runtime not initialized"); - } - return runtime; -} diff --git a/extensions/matrix-js/src/tool-actions.ts b/extensions/matrix-js/src/tool-actions.ts deleted file mode 100644 index d15dc24a953..00000000000 --- a/extensions/matrix-js/src/tool-actions.ts +++ /dev/null @@ -1,436 +0,0 @@ -import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "openclaw/plugin-sdk/matrix-js"; -import { - bootstrapMatrixVerification, - acceptMatrixVerification, - cancelMatrixVerification, - confirmMatrixVerificationReciprocateQr, - confirmMatrixVerificationSas, - deleteMatrixMessage, - editMatrixMessage, - generateMatrixVerificationQr, - getMatrixEncryptionStatus, - getMatrixRoomKeyBackupStatus, - getMatrixVerificationStatus, - getMatrixMemberInfo, - getMatrixRoomInfo, - getMatrixVerificationSas, - listMatrixPins, - listMatrixReactions, - listMatrixVerifications, - mismatchMatrixVerificationSas, - pinMatrixMessage, - readMatrixMessages, - requestMatrixVerification, - restoreMatrixRoomKeyBackup, - removeMatrixReactions, - scanMatrixVerificationQr, - sendMatrixMessage, - startMatrixVerification, - unpinMatrixMessage, - voteMatrixPoll, - verifyMatrixRecoveryKey, -} from "./matrix/actions.js"; -import { reactMatrixMessage } from "./matrix/send.js"; -import type { CoreConfig } from "./types.js"; - -const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); -const reactionActions = new Set(["react", "reactions"]); -const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); -const pollActions = new Set(["pollVote"]); -const verificationActions = new Set([ - "encryptionStatus", - "verificationList", - "verificationRequest", - "verificationAccept", - "verificationCancel", - "verificationStart", - "verificationGenerateQr", - "verificationScanQr", - "verificationSas", - "verificationConfirm", - "verificationMismatch", - "verificationConfirmQr", - "verificationStatus", - "verificationBootstrap", - "verificationRecoveryKey", - "verificationBackupStatus", - "verificationBackupRestore", -]); - -function readRoomId(params: Record, required = true): string { - const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); - if (direct) { - return direct; - } - if (!required) { - return readStringParam(params, "to") ?? ""; - } - return readStringParam(params, "to", { required: true }); -} - -function toSnakeCaseKey(key: string): string { - return key - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") - .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .toLowerCase(); -} - -function readRawParam(params: Record, 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, -): Promise> { - const action = readStringParam(params, "action", { required: true }); - const accountId = readStringParam(params, "accountId") ?? undefined; - const isActionEnabled = createActionGate(cfg.channels?.["matrix-js"]?.actions); - const clientOpts = accountId ? { accountId } : {}; - - if (reactionActions.has(action)) { - if (!isActionEnabled("reactions")) { - throw new Error("Matrix reactions are disabled."); - } - const roomId = readRoomId(params); - const messageId = readStringParam(params, "messageId", { required: true }); - if (action === "react") { - const { emoji, remove, isEmpty } = readReactionParams(params, { - removeErrorMessage: "Emoji is required to remove a Matrix reaction.", - }); - if (remove || isEmpty) { - const result = await removeMatrixReactions(roomId, messageId, { - accountId, - emoji: remove ? emoji : undefined, - }); - return jsonResult({ ok: true, removed: result.removed }); - } - await reactMatrixMessage(roomId, messageId, emoji, { accountId }); - return jsonResult({ ok: true, added: emoji }); - } - const limit = readNumberParam(params, "limit", { integer: true }); - const reactions = await listMatrixReactions(roomId, messageId, { - accountId, - limit: limit ?? undefined, - }); - return jsonResult({ ok: true, reactions }); - } - - if (pollActions.has(action)) { - const roomId = readRoomId(params); - const pollId = readStringParam(params, "pollId", { required: true }); - const optionId = readStringParam(params, "pollOptionId"); - const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true }); - const optionIds = [ - ...(readStringArrayParam(params, "pollOptionIds") ?? []), - ...(optionId ? [optionId] : []), - ]; - const optionIndexes = [ - ...readNumericArrayParam(params, "pollOptionIndexes", { integer: true }), - ...(optionIndex !== undefined ? [optionIndex] : []), - ]; - const result = await voteMatrixPoll(roomId, pollId, { - accountId, - optionIds, - optionIndexes, - }); - return jsonResult({ ok: true, result }); - } - - if (messageActions.has(action)) { - if (!isActionEnabled("messages")) { - throw new Error("Matrix messages are disabled."); - } - switch (action) { - case "sendMessage": { - const to = readStringParam(params, "to", { required: true }); - const content = readStringParam(params, "content", { - required: true, - allowEmpty: true, - }); - const mediaUrl = readStringParam(params, "mediaUrl"); - const replyToId = - readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo"); - const threadId = readStringParam(params, "threadId"); - const result = await sendMatrixMessage(to, content, { - mediaUrl: mediaUrl ?? undefined, - replyToId: replyToId ?? undefined, - threadId: threadId ?? undefined, - ...clientOpts, - }); - return jsonResult({ ok: true, result }); - } - case "editMessage": { - const roomId = readRoomId(params); - const messageId = readStringParam(params, "messageId", { required: true }); - const content = readStringParam(params, "content", { required: true }); - const result = await editMatrixMessage(roomId, messageId, content, clientOpts); - return jsonResult({ ok: true, result }); - } - case "deleteMessage": { - const roomId = readRoomId(params); - const messageId = readStringParam(params, "messageId", { required: true }); - const reason = readStringParam(params, "reason"); - await deleteMatrixMessage(roomId, messageId, { - reason: reason ?? undefined, - ...clientOpts, - }); - return jsonResult({ ok: true, deleted: true }); - } - case "readMessages": { - const roomId = readRoomId(params); - const limit = readNumberParam(params, "limit", { integer: true }); - const before = readStringParam(params, "before"); - const after = readStringParam(params, "after"); - const result = await readMatrixMessages(roomId, { - limit: limit ?? undefined, - before: before ?? undefined, - after: after ?? undefined, - ...clientOpts, - }); - return jsonResult({ ok: true, ...result }); - } - default: - break; - } - } - - if (pinActions.has(action)) { - if (!isActionEnabled("pins")) { - throw new Error("Matrix pins are disabled."); - } - const roomId = readRoomId(params); - if (action === "pinMessage") { - const messageId = readStringParam(params, "messageId", { required: true }); - const result = await pinMatrixMessage(roomId, messageId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned }); - } - if (action === "unpinMessage") { - const messageId = readStringParam(params, "messageId", { required: true }); - const result = await unpinMatrixMessage(roomId, messageId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned }); - } - const result = await listMatrixPins(roomId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); - } - - if (action === "memberInfo") { - if (!isActionEnabled("memberInfo")) { - throw new Error("Matrix member info is disabled."); - } - const userId = readStringParam(params, "userId", { required: true }); - const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); - const result = await getMatrixMemberInfo(userId, { - roomId: roomId ?? undefined, - ...clientOpts, - }); - return jsonResult({ ok: true, member: result }); - } - - if (action === "channelInfo") { - if (!isActionEnabled("channelInfo")) { - throw new Error("Matrix room info is disabled."); - } - const roomId = readRoomId(params); - const result = await getMatrixRoomInfo(roomId, clientOpts); - return jsonResult({ ok: true, room: result }); - } - - if (verificationActions.has(action)) { - if (!isActionEnabled("verification")) { - throw new Error("Matrix verification actions are disabled."); - } - - const requestId = - readStringParam(params, "requestId") ?? - readStringParam(params, "verificationId") ?? - readStringParam(params, "id"); - - if (action === "encryptionStatus") { - const includeRecoveryKey = params.includeRecoveryKey === true; - const status = await getMatrixEncryptionStatus({ includeRecoveryKey, accountId }); - return jsonResult({ ok: true, status }); - } - if (action === "verificationStatus") { - const includeRecoveryKey = params.includeRecoveryKey === true; - const status = await getMatrixVerificationStatus({ includeRecoveryKey, accountId }); - return jsonResult({ ok: true, status }); - } - if (action === "verificationBootstrap") { - const recoveryKey = - readStringParam(params, "recoveryKey", { trim: false }) ?? - readStringParam(params, "key", { trim: false }); - const result = await bootstrapMatrixVerification({ - recoveryKey: recoveryKey ?? undefined, - forceResetCrossSigning: params.forceResetCrossSigning === true, - accountId, - }); - return jsonResult({ ok: result.success, result }); - } - if (action === "verificationRecoveryKey") { - const recoveryKey = - readStringParam(params, "recoveryKey", { trim: false }) ?? - readStringParam(params, "key", { trim: false }); - const result = await verifyMatrixRecoveryKey( - readStringParam({ recoveryKey }, "recoveryKey", { required: true, trim: false }), - { accountId }, - ); - return jsonResult({ ok: result.success, result }); - } - if (action === "verificationBackupStatus") { - const status = await getMatrixRoomKeyBackupStatus({ accountId }); - return jsonResult({ ok: true, status }); - } - if (action === "verificationBackupRestore") { - const recoveryKey = - readStringParam(params, "recoveryKey", { trim: false }) ?? - readStringParam(params, "key", { trim: false }); - const result = await restoreMatrixRoomKeyBackup({ - recoveryKey: recoveryKey ?? undefined, - accountId, - }); - return jsonResult({ ok: result.success, result }); - } - if (action === "verificationList") { - const verifications = await listMatrixVerifications({ accountId }); - return jsonResult({ ok: true, verifications }); - } - if (action === "verificationRequest") { - const userId = readStringParam(params, "userId"); - const deviceId = readStringParam(params, "deviceId"); - const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); - const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined; - const verification = await requestMatrixVerification({ - ownUser, - userId: userId ?? undefined, - deviceId: deviceId ?? undefined, - roomId: roomId ?? undefined, - accountId, - }); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationAccept") { - const verification = await acceptMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationCancel") { - const reason = readStringParam(params, "reason"); - const code = readStringParam(params, "code"); - const verification = await cancelMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - { reason: reason ?? undefined, code: code ?? undefined, accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationStart") { - const methodRaw = readStringParam(params, "method"); - const method = methodRaw?.trim().toLowerCase(); - if (method && method !== "sas") { - throw new Error( - "Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.", - ); - } - const verification = await startMatrixVerification( - readStringParam({ requestId }, "requestId", { required: true }), - { method: "sas", accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationGenerateQr") { - const qr = await generateMatrixVerificationQr( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, ...qr }); - } - if (action === "verificationScanQr") { - const qrDataBase64 = - readStringParam(params, "qrDataBase64") ?? - readStringParam(params, "qrData") ?? - readStringParam(params, "qr"); - const verification = await scanMatrixVerificationQr( - readStringParam({ requestId }, "requestId", { required: true }), - readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationSas") { - const sas = await getMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, sas }); - } - if (action === "verificationConfirm") { - const verification = await confirmMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationMismatch") { - const verification = await mismatchMatrixVerificationSas( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, verification }); - } - if (action === "verificationConfirmQr") { - const verification = await confirmMatrixVerificationReciprocateQr( - readStringParam({ requestId }, "requestId", { required: true }), - { accountId }, - ); - return jsonResult({ ok: true, verification }); - } - } - - throw new Error(`Unsupported Matrix action: ${action}`); -} diff --git a/extensions/matrix-js/src/types.ts b/extensions/matrix-js/src/types.ts deleted file mode 100644 index 91f7ee896e5..00000000000 --- a/extensions/matrix-js/src/types.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/matrix-js"; -export type { DmPolicy, GroupPolicy }; - -export type ReplyToMode = "off" | "first" | "all"; - -export type MatrixDmConfig = { - /** If false, ignore all incoming Matrix DMs. Default: true. */ - enabled?: boolean; - /** Direct message access policy (default: pairing). */ - policy?: DmPolicy; - /** Allowlist for DM senders (matrix user IDs or "*"). */ - allowFrom?: Array; -}; - -export type MatrixRoomConfig = { - /** If false, disable the bot in this room (alias for allow: false). */ - enabled?: boolean; - /** Legacy room allow toggle; prefer enabled. */ - allow?: boolean; - /** Require mentioning the bot to trigger replies. */ - requireMention?: boolean; - /** Optional tool policy overrides for this room. */ - tools?: { allow?: string[]; deny?: string[] }; - /** If true, reply without mention requirements. */ - autoReply?: boolean; - /** Optional allowlist for room senders (matrix user IDs). */ - users?: Array; - /** Optional skill filter for this room. */ - skills?: string[]; - /** Optional system prompt snippet for this room. */ - systemPrompt?: string; -}; - -export type MatrixActionConfig = { - reactions?: boolean; - messages?: boolean; - pins?: boolean; - memberInfo?: boolean; - channelInfo?: boolean; - verification?: boolean; -}; - -export type MatrixThreadBindingsConfig = { - enabled?: boolean; - idleHours?: number; - maxAgeHours?: number; - spawnSubagentSessions?: boolean; - spawnAcpSessions?: boolean; -}; - -/** Per-account Matrix config (excludes the accounts field to prevent recursion). */ -export type MatrixAccountConfig = Omit; - -export type MatrixConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** If false, do not start Matrix. Default: true. */ - enabled?: boolean; - /** Multi-account configuration keyed by account ID. */ - accounts?: Record; - /** Matrix homeserver URL (https://matrix.example.org). */ - homeserver?: string; - /** Matrix user id (@user:server). */ - userId?: string; - /** Matrix access token. */ - accessToken?: string; - /** Matrix password (used only to fetch access token). */ - password?: string; - /** Optional Matrix device id (recommended when using access tokens + E2EE). */ - deviceId?: string; - /** Optional device name when logging in via password. */ - deviceName?: string; - /** Optional desired Matrix avatar source (mxc:// or http(s) URL). */ - avatarUrl?: string; - /** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */ - initialSyncLimit?: number; - /** Enable end-to-end encryption (E2EE). Default: false. */ - encryption?: boolean; - /** If true, enforce allowlists for groups + DMs regardless of policy. */ - allowlistOnly?: boolean; - /** Group message policy (default: allowlist). */ - groupPolicy?: GroupPolicy; - /** Allowlist for group senders (matrix user IDs). */ - groupAllowFrom?: Array; - /** Control reply threading when reply tags are present (off|first|all). */ - replyToMode?: ReplyToMode; - /** How to handle thread replies (off|inbound|always). */ - threadReplies?: "off" | "inbound" | "always"; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ - chunkMode?: "length" | "newline"; - /** Outbound response prefix override for this channel/account. */ - responsePrefix?: string; - /** Ack reaction emoji override for this channel/account. */ - ackReaction?: string; - /** Ack reaction scope override for this channel/account. */ - ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off"; - /** Inbound reaction notifications for bot-authored Matrix messages. */ - reactionNotifications?: "off" | "own"; - /** Thread/session binding behavior for Matrix room threads. */ - threadBindings?: MatrixThreadBindingsConfig; - /** Whether Matrix-js should auto-request self verification on startup when unverified. */ - startupVerification?: "off" | "if-unverified"; - /** Cooldown window for automatic startup verification requests. Default: 24 hours. */ - startupVerificationCooldownHours?: number; - /** Max outbound media size in MB. */ - mediaMaxMb?: number; - /** Auto-join invites (always|allowlist|off). Default: always. */ - autoJoin?: "always" | "allowlist" | "off"; - /** Allowlist for auto-join invites (room IDs, aliases). */ - autoJoinAllowlist?: Array; - /** Direct message policy + allowlist overrides. */ - dm?: MatrixDmConfig; - /** Room config allowlist keyed by room ID or alias (names resolved to IDs when possible). */ - groups?: Record; - /** Room config allowlist keyed by room ID or alias. Legacy; use groups. */ - rooms?: Record; - /** Per-action tool gating (default: true for all). */ - actions?: MatrixActionConfig; -}; - -export type CoreConfig = { - channels?: { - "matrix-js"?: MatrixConfig; - defaults?: { - groupPolicy?: "open" | "allowlist" | "disabled"; - }; - }; - commands?: { - useAccessGroups?: boolean; - }; - session?: { - store?: string; - }; - messages?: { - ackReaction?: string; - ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all" | "none" | "off"; - }; - [key: string]: unknown; -}; diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 9e4863a1ed8..4cd54f42ed8 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,6 +1,12 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/matrix"; +import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk/matrix"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/matrix"; import { matrixPlugin } from "./src/channel.js"; +import { registerMatrixCli } from "./src/cli.js"; +import { + bootstrapMatrixVerification, + getMatrixVerificationStatus, + verifyMatrixRecoveryKey, +} from "./src/matrix/actions/verification.js"; import { ensureMatrixCryptoRuntime } from "./src/matrix/deps.js"; import { setMatrixRuntime } from "./src/runtime.js"; @@ -11,11 +17,83 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setMatrixRuntime(api.runtime); - void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err) => { + void ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err); api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`); }); api.registerChannel({ plugin: matrixPlugin }); + + const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + }; + + api.registerGatewayMethod( + "matrix.verify.recoveryKey", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const key = typeof params?.key === "string" ? params.key : ""; + if (!key.trim()) { + respond(false, { error: "key required" }); + return; + } + const accountId = + typeof params?.accountId === "string" + ? params.accountId.trim() || undefined + : undefined; + const result = await verifyMatrixRecoveryKey(key, { accountId }); + respond(result.success, result); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "matrix.verify.bootstrap", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const accountId = + typeof params?.accountId === "string" + ? params.accountId.trim() || undefined + : undefined; + const recoveryKey = + typeof params?.recoveryKey === "string" ? params.recoveryKey : undefined; + const forceResetCrossSigning = params?.forceResetCrossSigning === true; + const result = await bootstrapMatrixVerification({ + accountId, + recoveryKey, + forceResetCrossSigning, + }); + respond(result.success, result); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerGatewayMethod( + "matrix.verify.status", + async ({ params, respond }: GatewayRequestHandlerOptions) => { + try { + const accountId = + typeof params?.accountId === "string" + ? params.accountId.trim() || undefined + : undefined; + const includeRecoveryKey = params?.includeRecoveryKey === true; + const status = await getMatrixVerificationStatus({ accountId, includeRecoveryKey }); + respond(true, status); + } catch (err) { + sendError(respond, err); + } + }, + ); + + api.registerCli( + ({ program }) => { + registerMatrixCli({ program }); + }, + { commands: ["matrix"] }, + ); }, }; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index c1b5859b43e..12b17db4509 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,16 +1,19 @@ { "name": "@openclaw/matrix", - "version": "2026.3.9", + "version": "2026.3.8", "description": "OpenClaw Matrix channel plugin", "type": "module", "dependencies": { - "@mariozechner/pi-agent-core": "0.57.1", "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", - "@vector-im/matrix-bot-sdk": "0.8.0-element.3", - "markdown-it": "14.1.1", - "music-metadata": "^11.12.1", + "fake-indexeddb": "^6.2.5", + "markdown-it": "14.1.0", + "matrix-js-sdk": "^40.1.0", + "music-metadata": "^11.11.2", "zod": "^4.3.6" }, + "devDependencies": { + "openclaw": "workspace:*" + }, "openclaw": { "extensions": [ "./index.ts" @@ -33,7 +36,7 @@ "releaseChecks": { "rootDependencyMirrorAllowlist": [ "@matrix-org/matrix-sdk-crypto-nodejs", - "@vector-im/matrix-bot-sdk", + "matrix-js-sdk", "music-metadata" ] } diff --git a/extensions/matrix-js/scripts/live-basic-send.ts b/extensions/matrix/scripts/live-basic-send.ts similarity index 90% rename from extensions/matrix-js/scripts/live-basic-send.ts rename to extensions/matrix/scripts/live-basic-send.ts index 535133e6fb2..c4a792e7d72 100644 --- a/extensions/matrix-js/scripts/live-basic-send.ts +++ b/extensions/matrix/scripts/live-basic-send.ts @@ -29,7 +29,7 @@ async function main() { invite: [targetUserId], preset: "trusted_private_chat", name: `OpenClaw DM Test ${stamp}`, - topic: "matrix-js basic DM messaging test", + topic: "matrix basic DM messaging test", }, )) as { room_id?: string }; @@ -48,12 +48,12 @@ async function main() { const dmByUserTarget = await sendMatrixMessage( targetUserId, - `Matrix-js basic DM test (user target) ${stamp}`, + `Matrix basic DM test (user target) ${stamp}`, { client }, ); const dmByRoomTarget = await sendMatrixMessage( dmRoomId, - `Matrix-js basic DM test (room target) ${stamp}`, + `Matrix basic DM test (room target) ${stamp}`, { client }, ); @@ -61,7 +61,7 @@ async function main() { invite: [targetUserId], preset: "private_chat", name: `OpenClaw Room Test ${stamp}`, - topic: "matrix-js basic room messaging test", + topic: "matrix basic room messaging test", })) as { room_id?: string }; const roomId = roomCreate.room_id?.trim() ?? ""; @@ -69,7 +69,7 @@ async function main() { throw new Error("Failed to create room chat room"); } - const roomSend = await sendMatrixMessage(roomId, `Matrix-js basic room test ${stamp}`, { + const roomSend = await sendMatrixMessage(roomId, `Matrix basic room test ${stamp}`, { client, }); diff --git a/extensions/matrix-js/scripts/live-common.ts b/extensions/matrix/scripts/live-common.ts similarity index 94% rename from extensions/matrix-js/scripts/live-common.ts rename to extensions/matrix/scripts/live-common.ts index 6c9e9cf7dcf..50333ad7d0a 100644 --- a/extensions/matrix-js/scripts/live-common.ts +++ b/extensions/matrix/scripts/live-common.ts @@ -82,7 +82,7 @@ export function resolveLiveHarnessConfig(): LiveHarnessConfig { export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { channels: { - "matrix-js": { + matrix: { homeserver: string; userId: string; password: string; @@ -92,7 +92,7 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { } { const pluginCfg = { channels: { - "matrix-js": { + matrix: { homeserver: cfg.homeserver, userId: cfg.userId, password: cfg.password, @@ -106,7 +106,7 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { loadConfig: () => pluginCfg, }, state: { - resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-js-live-harness-state"), + resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-live-harness-state"), }, channel: { text: { @@ -133,9 +133,9 @@ export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { }, isVoiceCompatibleAudio: () => false, loadWebMedia: async () => ({ - buffer: Buffer.from("matrix-js harness media payload\n", "utf8"), + buffer: Buffer.from("matrix harness media payload\n", "utf8"), contentType: "text/plain", - fileName: "matrix-js-harness.txt", + fileName: "matrix-harness.txt", kind: "document" as const, }), }, diff --git a/extensions/matrix-js/scripts/live-cross-signing-probe.ts b/extensions/matrix/scripts/live-cross-signing-probe.ts similarity index 98% rename from extensions/matrix-js/scripts/live-cross-signing-probe.ts rename to extensions/matrix/scripts/live-cross-signing-probe.ts index 65095f37f53..6b5051a112a 100644 --- a/extensions/matrix-js/scripts/live-cross-signing-probe.ts +++ b/extensions/matrix/scripts/live-cross-signing-probe.ts @@ -15,7 +15,7 @@ type MatrixCryptoProbe = { async function main() { const base = resolveLiveHarnessConfig(); const cfg = installLiveHarnessRuntime(base); - (cfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (cfg.channels["matrix"] as { encryption: boolean }).encryption = true; const auth = await resolveMatrixAuth({ cfg: cfg as never }); const client = await createMatrixClient({ diff --git a/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts b/extensions/matrix/scripts/live-e2ee-bootstrap.ts similarity index 91% rename from extensions/matrix-js/scripts/live-e2ee-bootstrap.ts rename to extensions/matrix/scripts/live-e2ee-bootstrap.ts index c52e7f922fa..a24fb3a071a 100644 --- a/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts +++ b/extensions/matrix/scripts/live-e2ee-bootstrap.ts @@ -7,7 +7,7 @@ async function main() { const base = resolveLiveHarnessConfig(); const pluginCfg = installLiveHarnessRuntime(base); - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const result = await bootstrapMatrixVerification({ recoveryKey: recoveryKeyArg?.trim() || undefined, diff --git a/extensions/matrix-js/scripts/live-e2ee-room-state.ts b/extensions/matrix/scripts/live-e2ee-room-state.ts similarity index 90% rename from extensions/matrix-js/scripts/live-e2ee-room-state.ts rename to extensions/matrix/scripts/live-e2ee-room-state.ts index af71d30be77..b8af65f3812 100644 --- a/extensions/matrix-js/scripts/live-e2ee-room-state.ts +++ b/extensions/matrix/scripts/live-e2ee-room-state.ts @@ -7,13 +7,13 @@ async function main() { if (!roomId) { throw new Error( - "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-room-state.ts [eventId]", + "Usage: node --import tsx extensions/matrix/scripts/live-e2ee-room-state.ts [eventId]", ); } const base = resolveLiveHarnessConfig(); const pluginCfg = installLiveHarnessRuntime(base); - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); const client = await createMatrixClient({ diff --git a/extensions/matrix-js/scripts/live-e2ee-send-room.ts b/extensions/matrix/scripts/live-e2ee-send-room.ts similarity index 90% rename from extensions/matrix-js/scripts/live-e2ee-send-room.ts rename to extensions/matrix/scripts/live-e2ee-send-room.ts index f11510da077..02b253ce385 100644 --- a/extensions/matrix-js/scripts/live-e2ee-send-room.ts +++ b/extensions/matrix/scripts/live-e2ee-send-room.ts @@ -15,13 +15,13 @@ async function main() { if (!roomId) { throw new Error( - "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-send-room.ts [settleMs] [--full-bootstrap]", + "Usage: node --import tsx extensions/matrix/scripts/live-e2ee-send-room.ts [settleMs] [--full-bootstrap]", ); } const base = resolveLiveHarnessConfig(); const pluginCfg = installLiveHarnessRuntime(base); - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); const client = await createMatrixClient({ @@ -64,7 +64,7 @@ async function main() { const sent = await sendMatrixMessage( roomId, - `Matrix-js E2EE existing-room test ${stamp} (settleMs=${settleMs})`, + `Matrix E2EE existing-room test ${stamp} (settleMs=${settleMs})`, { client }, ); diff --git a/extensions/matrix-js/scripts/live-e2ee-send.ts b/extensions/matrix/scripts/live-e2ee-send.ts similarity index 93% rename from extensions/matrix-js/scripts/live-e2ee-send.ts rename to extensions/matrix/scripts/live-e2ee-send.ts index 9f9e71738e9..aec6b65bdbb 100644 --- a/extensions/matrix-js/scripts/live-e2ee-send.ts +++ b/extensions/matrix/scripts/live-e2ee-send.ts @@ -16,7 +16,7 @@ async function main() { const pluginCfg = installLiveHarnessRuntime(base); // Enable encryption for this run only. - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); const client = await createMatrixClient({ @@ -62,7 +62,7 @@ async function main() { invite: [targetUserId], preset: "trusted_private_chat", name: `OpenClaw E2EE DM ${stamp}`, - topic: "matrix-js E2EE DM test", + topic: "matrix E2EE DM test", initial_state: [ { type: "m.room.encryption", @@ -90,7 +90,7 @@ async function main() { const dmSend = await sendMatrixMessage( dmRoomId, - `Matrix-js E2EE DM test ${stamp}\nPlease reply here so I can validate decrypt/read.`, + `Matrix E2EE DM test ${stamp}\nPlease reply here so I can validate decrypt/read.`, { client, }, @@ -100,7 +100,7 @@ async function main() { invite: [targetUserId], preset: "private_chat", name: `OpenClaw E2EE Room ${stamp}`, - topic: "matrix-js E2EE room test", + topic: "matrix E2EE room test", initial_state: [ { type: "m.room.encryption", @@ -119,7 +119,7 @@ async function main() { const roomSend = await sendMatrixMessage( roomId, - `Matrix-js E2EE room test ${stamp}\nPlease reply here too.`, + `Matrix E2EE room test ${stamp}\nPlease reply here too.`, { client, }, diff --git a/extensions/matrix-js/scripts/live-e2ee-status.ts b/extensions/matrix/scripts/live-e2ee-status.ts similarity index 95% rename from extensions/matrix-js/scripts/live-e2ee-status.ts rename to extensions/matrix/scripts/live-e2ee-status.ts index 0096da3b78b..520d001bc84 100644 --- a/extensions/matrix-js/scripts/live-e2ee-status.ts +++ b/extensions/matrix/scripts/live-e2ee-status.ts @@ -11,7 +11,7 @@ async function main() { const base = resolveLiveHarnessConfig(); const pluginCfg = installLiveHarnessRuntime(base); - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const verification = await getMatrixVerificationStatus({ includeRecoveryKey, diff --git a/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts b/extensions/matrix/scripts/live-e2ee-wait-reply.ts similarity index 93% rename from extensions/matrix-js/scripts/live-e2ee-wait-reply.ts rename to extensions/matrix/scripts/live-e2ee-wait-reply.ts index ad57cca0da0..9eb88feb197 100644 --- a/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts +++ b/extensions/matrix/scripts/live-e2ee-wait-reply.ts @@ -24,13 +24,13 @@ async function main() { if (!roomId) { throw new Error( - "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-wait-reply.ts [targetUserId] [timeoutSec] [--full-bootstrap]", + "Usage: node --import tsx extensions/matrix/scripts/live-e2ee-wait-reply.ts [targetUserId] [timeoutSec] [--full-bootstrap]", ); } const base = resolveLiveHarnessConfig(); const pluginCfg = installLiveHarnessRuntime(base); - (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + (pluginCfg.channels["matrix"] as { encryption: boolean }).encryption = true; const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); const client = await createMatrixClient({ diff --git a/extensions/matrix-js/scripts/live-read-room.ts b/extensions/matrix/scripts/live-read-room.ts similarity index 94% rename from extensions/matrix-js/scripts/live-read-room.ts rename to extensions/matrix/scripts/live-read-room.ts index 27607fc9412..0ff9c473efb 100644 --- a/extensions/matrix-js/scripts/live-read-room.ts +++ b/extensions/matrix/scripts/live-read-room.ts @@ -5,7 +5,7 @@ import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-comm async function main() { const roomId = process.argv[2]?.trim(); if (!roomId) { - throw new Error("Usage: bun extensions/matrix-js/scripts/live-read-room.ts [limit]"); + throw new Error("Usage: bun extensions/matrix/scripts/live-read-room.ts [limit]"); } const requestedLimit = Number.parseInt(process.argv[3] ?? "30", 10); diff --git a/extensions/matrix-js/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts similarity index 96% rename from extensions/matrix-js/src/actions.account-propagation.test.ts rename to extensions/matrix/src/actions.account-propagation.test.ts index 4218088fbcd..adceb03ab95 100644 --- a/extensions/matrix-js/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -1,4 +1,4 @@ -import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix-js"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "./types.js"; @@ -16,11 +16,11 @@ function createContext( overrides: Partial, ): ChannelMessageActionContext { return { - channel: "matrix-js", + channel: "matrix", action: "send", cfg: { channels: { - "matrix-js": { + matrix: { enabled: true, homeserver: "https://matrix.example.org", userId: "@bot:example.org", diff --git a/extensions/matrix-js/src/actions.test.ts b/extensions/matrix/src/actions.test.ts similarity index 88% rename from extensions/matrix-js/src/actions.test.ts rename to extensions/matrix/src/actions.test.ts index 582dbb54408..72341362d33 100644 --- a/extensions/matrix-js/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +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"; @@ -18,7 +18,7 @@ const runtimeStub = { resizeToJpeg: async () => Buffer.from(""), }, state: { - resolveStateDir: () => "/tmp/openclaw-matrix-js-test", + resolveStateDir: () => "/tmp/openclaw-matrix-test", }, channel: { text: { @@ -32,10 +32,10 @@ const runtimeStub = { }, } as unknown as PluginRuntime; -function createConfiguredMatrixJsConfig(): CoreConfig { +function createConfiguredMatrixConfig(): CoreConfig { return { channels: { - "matrix-js": { + matrix: { enabled: true, homeserver: "https://matrix.example.org", userId: "@bot:example.org", @@ -58,7 +58,7 @@ describe("matrixMessageActions", () => { expect(supportsAction).toBeTypeOf("function"); const actions = listActions!({ - cfg: createConfiguredMatrixJsConfig(), + cfg: createConfiguredMatrixConfig(), } as never); expect(actions).toContain("poll"); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 9e7e0a0653e..022e87300ea 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -11,14 +11,34 @@ import { resolveMatrixAccount } from "./matrix/accounts.js"; import { handleMatrixAction } from "./tool-actions.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", + "member-info", + "channel-info", + "permissions", +]); + +function createMatrixExposedActions() { + return new Set(["poll", ...MATRIX_PLUGIN_HANDLED_ACTIONS]); +} + export const matrixMessageActions: ChannelMessageActionAdapter = { listActions: ({ cfg }) => { const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); if (!account.enabled || !account.configured) { return []; } - const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions); - const actions = new Set(["send", "poll"]); + const gate = createActionGate((cfg as CoreConfig).channels?.["matrix"]?.actions); + const actions = createMatrixExposedActions(); if (gate("reactions")) { actions.add("react"); actions.add("reactions"); @@ -39,9 +59,12 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { if (gate("channelInfo")) { actions.add("channel-info"); } + if (account.config.encryption === true && gate("verification")) { + actions.add("permissions"); + } return Array.from(actions); }, - 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 +77,15 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { return { to }; }, handleAction: async (ctx: ChannelMessageActionContext) => { - const { action, params, cfg } = ctx; + const { action, params, cfg, accountId } = ctx; + const dispatch = async (actionParams: Record) => + await handleMatrixAction( + { + ...actionParams, + ...(accountId ? { accountId } : {}), + }, + cfg as CoreConfig, + ); const resolveRoomId = () => readStringParam(params, "roomId") ?? readStringParam(params, "channelId") ?? @@ -69,87 +100,76 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { 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 +177,68 @@ 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 === "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/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 51c781c0b75..5dc08b1d102 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -4,25 +4,6 @@ import { matrixPlugin } from "./channel.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; -vi.mock("@vector-im/matrix-bot-sdk", () => ({ - ConsoleLogger: class { - trace = vi.fn(); - debug = vi.fn(); - info = vi.fn(); - warn = vi.fn(); - error = vi.fn(); - }, - MatrixClient: class {}, - LogService: { - setLogger: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - debug: vi.fn(), - }, - SimpleFsStorageProvider: class {}, - RustSdkCryptoStorageProvider: class {}, -})); - describe("matrix directory", () => { const runtimeEnv: RuntimeEnv = { log: vi.fn(), @@ -122,6 +103,59 @@ 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("resolves group mention policy from account config", () => { const cfg = { channels: { @@ -151,4 +185,209 @@ describe("matrix directory", () => { }), ).toBe(false); }); + + it("writes matrix non-default account credentials under channels.matrix.accounts", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://default.example.org", + accessToken: "default-token", + 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"]?.accounts?.default).toMatchObject({ + accessToken: "default-token", + homeserver: "https://default.example.org", + 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", + }); + }); + + 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("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", + }, + }) 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", + }, + }, + }, + }, + } 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.ts b/extensions/matrix/src/channel.ts index a024b3f3e8a..909211742bd 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -1,18 +1,16 @@ -import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, - createScopedAccountConfigAccessors, - createScopedChannelConfigBase, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + moveSingleAccountChannelSectionToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + setAccountEnabledInConfigSection, + type ChannelSetupInput, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; import { matrixMessageActions } from "./actions.js"; @@ -29,14 +27,28 @@ import { resolveMatrixAccount, type ResolvedMatrixAccount, } from "./matrix/accounts.js"; -import { resolveMatrixAuth } from "./matrix/client.js"; +import { + getMatrixScopedEnvVarNames, + hasReadyMatrixEnvAuth, + resolveMatrixAuth, + resolveScopedMatrixEnvConfig, +} from "./matrix/client.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { resolveMatrixConfigFieldPath, resolveMatrixConfigPath } from "./matrix/config-update.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import { probeMatrix } from "./matrix/probe.js"; +import { isSupportedMatrixAvatarSource } from "./matrix/profile.js"; import { sendMessageMatrix } from "./matrix/send.js"; +import { + isMatrixQualifiedUserId, + normalizeMatrixDirectoryGroupId, + normalizeMatrixDirectoryUserId, + normalizeMatrixMessagingTarget, + resolveMatrixDirectUserId, +} from "./matrix/target-ids.js"; import { matrixOnboardingAdapter } from "./onboarding.js"; import { matrixOutbound } from "./outbound.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; -import { normalizeSecretInputString } from "./secret-input.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -53,82 +65,12 @@ 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; +function resolveAvatarInput(input: ChannelSetupInput): string | undefined { + const avatarUrl = (input as ChannelSetupInput & { avatarUrl?: string }).avatarUrl; + const trimmed = avatarUrl?.trim(); + return trimmed ? trimmed : undefined; } -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - 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 } - : {}), - }, - }, - }; -} - -const matrixConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), - resolveAllowFrom: (account) => account.dm?.allowFrom, - formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom), -}); - -const matrixConfigBase = createScopedChannelConfigBase({ - sectionKey: "matrix", - listAccountIds: listMatrixAccountIds, - resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultMatrixAccountId, - clearBaseFields: [ - "name", - "homeserver", - "userId", - "accessToken", - "password", - "deviceName", - "initialSyncLimit", - ], -}); - -const resolveMatrixDmPolicy = createScopedDmSecurityResolver({ - channelKey: "matrix", - resolvePolicy: (account) => account.config.dm?.policy, - resolveAllowFrom: (account) => account.config.dm?.allowFrom, - allowFromPathSuffix: "dm.", - normalizeEntry: (raw) => normalizeMatrixUserId(raw), -}); - export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, @@ -136,8 +78,10 @@ export const matrixPlugin: ChannelPlugin = { pairing: { idLabel: "matrixUserId", normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), - notifyApproval: async ({ id }) => { - await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + notifyApproval: async ({ id, accountId }) => { + await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE, { + accountId, + }); }, }, capabilities: { @@ -150,7 +94,33 @@ export const matrixPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.matrix"] }, configSchema: buildChannelConfigSchema(MatrixConfigSchema), config: { - ...matrixConfigBase, + listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + clearBaseFields: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "avatarUrl", + "initialSyncLimit", + ], + }), isConfigured: (account) => account.configured, describeAccount: (account) => ({ accountId: account.accountId, @@ -159,27 +129,41 @@ export const matrixPlugin: ChannelPlugin = { configured: account.configured, baseUrl: account.homeserver, }), - ...matrixConfigAccessors, + resolveAllowFrom: ({ cfg, accountId }) => { + const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }); + return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry)); + }, + formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom), }, security: { - resolveDmPolicy: resolveMatrixDmPolicy, + resolveDmPolicy: ({ account, cfg }) => { + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + policyPath: resolveMatrixConfigFieldPath(cfg as CoreConfig, account.accountId, "dm.policy"), + allowFromPath: resolveMatrixConfigFieldPath( + cfg as CoreConfig, + account.accountId, + "dm.allowFrom", + ), + approveHint: formatPairingApproveHint("matrix"), + normalizeEntry: (raw) => normalizeMatrixUserId(raw), + }; + }, collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderGroupPolicyWarnings({ - cfg: cfg as CoreConfig, - providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "Matrix rooms", - openBehavior: "allows any room to trigger (mention-gated)", - remediation: - 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', - }), - ] - : [], + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.["matrix"] !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, }); + if (groupPolicy !== "open") { + return []; + } + const configPath = resolveMatrixConfigPath(cfg as CoreConfig, account.accountId); + return [ + `- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set ${configPath}.groupPolicy="allowlist" + ${configPath}.groups (and optionally ${configPath}.groupAllowFrom) to restrict rooms.`, + ]; }, }, groups: { @@ -194,7 +178,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, }; }, @@ -223,48 +212,35 @@ export const matrixPlugin: ChannelPlugin = { const ids = new Set(); for (const entry of account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; + const normalized = normalizeMatrixDirectoryUserId(String(entry)); + if (normalized) { + ids.add(normalized); } - ids.add(raw.replace(/^matrix:/i, "")); } for (const entry of account.config.groupAllowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; + const normalized = normalizeMatrixDirectoryUserId(String(entry)); + if (normalized) { + ids.add(normalized); } - ids.add(raw.replace(/^matrix:/i, "")); } const groups = account.config.groups ?? account.config.rooms ?? {}; for (const room of Object.values(groups)) { for (const entry of room.users ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; + const normalized = normalizeMatrixDirectoryUserId(String(entry)); + if (normalized) { + ids.add(normalized); } - ids.add(raw.replace(/^matrix:/i, "")); } } return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { - const lowered = raw.toLowerCase(); - const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; - if (cleaned.startsWith("@")) { - return `user:${cleaned}`; - } - return cleaned; - }) .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => { const raw = id.startsWith("user:") ? id.slice("user:".length) : id; - const incomplete = !raw.startsWith("@") || !raw.includes(":"); + const incomplete = !isMatrixQualifiedUserId(raw); return { kind: "user", id, @@ -277,19 +253,8 @@ export const matrixPlugin: ChannelPlugin = { const q = query?.trim().toLowerCase() || ""; const groups = account.config.groups ?? account.config.rooms ?? {}; const ids = Object.keys(groups) - .map((raw) => raw.trim()) - .filter((raw) => Boolean(raw) && raw !== "*") - .map((raw) => raw.replace(/^matrix:/i, "")) - .map((raw) => { - const lowered = raw.toLowerCase(); - if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { - return raw; - } - if (raw.startsWith("!")) { - return `room:${raw}`; - } - return raw; - }) + .map((raw) => normalizeMatrixDirectoryGroupId(raw)) + .filter((id): id is string => Boolean(id)) .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "group", id }) as const); @@ -301,12 +266,15 @@ export const matrixPlugin: ChannelPlugin = { listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }), }, resolver: { - resolveTargets: async ({ cfg, inputs, kind, runtime }) => - resolveMatrixTargets({ cfg, inputs, kind, runtime }), + resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => + resolveMatrixTargets({ cfg, accountId, inputs, kind, runtime }), }, actions: matrixMessageActions, setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + resolveAccountId: ({ accountId, input }) => + normalizeAccountId(accountId?.trim() || input?.name?.trim()), + resolveBindingAccountId: ({ agentId, accountId }) => + normalizeAccountId(accountId?.trim() || agentId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg: cfg as CoreConfig, @@ -314,15 +282,25 @@ export const matrixPlugin: ChannelPlugin = { accountId, name, }), - validateInput: ({ input }) => { + validateInput: ({ accountId, input }) => { + const avatarUrl = resolveAvatarInput(input); + if (avatarUrl && !isSupportedMatrixAvatarSource(avatarUrl)) { + return "Matrix avatar URL must be an mxc:// URI or an http(s) URL"; + } if (input.useEnv) { + const scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); + const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); + if (accountId !== DEFAULT_ACCOUNT_ID && !scopedReady) { + const keys = getMatrixScopedEnvVarNames(accountId); + return `Set per-account env vars for "${accountId}" (for example ${keys.homeserver} + ${keys.accessToken} or ${keys.userId} + ${keys.password}).`; + } return null; } if (!input.homeserver?.trim()) { return "Matrix requires --homeserver"; } const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); + const password = input.password?.trim(); const userId = input.userId?.trim(); if (!accessToken && !password) { return "Matrix requires --access-token or --password"; @@ -337,31 +315,40 @@ export const matrixPlugin: ChannelPlugin = { } return null; }, - applyAccountConfig: ({ cfg, input }) => { + applyAccountConfig: ({ cfg, accountId, input }) => { + const promoted = + normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID + ? moveSingleAccountChannelSectionToDefaultAccount({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + }) + : (cfg as CoreConfig); const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, + cfg: promoted, channelKey: "matrix", - accountId: DEFAULT_ACCOUNT_ID, + accountId, name: input.name, }); + const next = namedConfig as CoreConfig; if (input.useEnv) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - matrix: { - ...namedConfig.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; + return setAccountEnabledInConfigSection({ + cfg: next as CoreConfig, + sectionKey: "matrix", + accountId, + enabled: true, + allowTopLevel: true, + }) as CoreConfig; } - return buildMatrixConfigUpdate(namedConfig as CoreConfig, { + const accessToken = input.accessToken?.trim(); + const password = input.password?.trim(); + const userId = input.userId?.trim(); + return updateMatrixAccountConfig(next as CoreConfig, accountId, { homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), + userId: password && !userId ? null : userId, + accessToken: accessToken || (password ? null : undefined), + password: password || (accessToken ? null : undefined), deviceName: input.deviceName?.trim(), + avatarUrl: resolveAvatarInput(input), initialSyncLimit: input.initialSyncLimit, }); }, @@ -375,9 +362,31 @@ export const matrixPlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, - collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts), - buildChannelSummary: ({ snapshot }) => - buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }), + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) { + return []; + } + return [ + { + channel: "matrix", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), probeAccount: async ({ account, timeoutMs, cfg }) => { try { const auth = await resolveMatrixAuth({ @@ -389,6 +398,7 @@ export const matrixPlugin: ChannelPlugin = { accessToken: auth.accessToken, userId: auth.userId, timeoutMs, + accountId: account.accountId, }); } catch (err) { return { diff --git a/extensions/matrix-js/src/cli.test.ts b/extensions/matrix/src/cli.test.ts similarity index 89% rename from extensions/matrix-js/src/cli.test.ts rename to extensions/matrix/src/cli.test.ts index 36c3efbf6af..60e213a89c4 100644 --- a/extensions/matrix-js/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-js"; +import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const bootstrapMatrixVerificationMock = vi.fn(); @@ -48,11 +48,11 @@ vi.mock("./runtime.js", () => ({ }), })); -let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli; +let registerMatrixCli: typeof import("./cli.js").registerMatrixCli; function buildProgram(): Command { const program = new Command(); - registerMatrixJsCli({ program }); + registerMatrixCli({ program }); return program; } @@ -60,12 +60,12 @@ function formatExpectedLocalTimestamp(value: string): string { return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value; } -describe("matrix-js CLI verification commands", () => { +describe("matrix CLI verification commands", () => { beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); process.exitCode = undefined; - ({ registerMatrixJsCli } = await import("./cli.js")); + ({ registerMatrixCli } = await import("./cli.js")); vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); matrixSetupValidateInputMock.mockReturnValue(null); @@ -93,7 +93,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], { + await program.parseAsync(["matrix", "verify", "device", "bad-key", "--json"], { from: "user", }); @@ -111,7 +111,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" }); + await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" }); expect(process.exitCode).toBe(1); }); @@ -134,21 +134,21 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], { + await program.parseAsync(["matrix", "verify", "backup", "restore", "--json"], { from: "user", }); expect(process.exitCode).toBe(1); }); - it("adds a matrix-js account and prints a binding hint", async () => { + 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-js": { + matrix: { accounts: { [accountId]: { homeserver: "https://matrix.example.org", @@ -162,7 +162,7 @@ describe("matrix-js CLI verification commands", () => { await program.parseAsync( [ - "matrix-js", + "matrix", "account", "add", "--account", @@ -190,7 +190,7 @@ describe("matrix-js CLI verification commands", () => { expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ channels: { - "matrix-js": { + matrix: { accounts: { ops: expect.objectContaining({ homeserver: "https://matrix.example.org", @@ -200,9 +200,9 @@ describe("matrix-js CLI verification commands", () => { }, }), ); - expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops"); + expect(console.log).toHaveBeenCalledWith("Saved matrix account: ops"); expect(console.log).toHaveBeenCalledWith( - "Bind this account to an agent: openclaw agents bind --agent --bind matrix-js:ops", + "Bind this account to an agent: openclaw agents bind --agent --bind matrix:ops", ); }); @@ -212,7 +212,7 @@ describe("matrix-js CLI verification commands", () => { await program.parseAsync( [ - "matrix-js", + "matrix", "account", "add", "--name", @@ -232,8 +232,8 @@ describe("matrix-js CLI verification commands", () => { accountId: "main-bot", }), ); - expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot"); - expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.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", @@ -241,7 +241,7 @@ describe("matrix-js CLI verification commands", () => { }), ); expect(console.log).toHaveBeenCalledWith( - "Bind this account to an agent: openclaw agents bind --agent --bind matrix-js:main-bot", + "Bind this account to an agent: openclaw agents bind --agent --bind matrix:main-bot", ); }); @@ -250,7 +250,7 @@ describe("matrix-js CLI verification commands", () => { await program.parseAsync( [ - "matrix-js", + "matrix", "profile", "set", "--account", @@ -272,14 +272,14 @@ describe("matrix-js CLI verification commands", () => { ); expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("Account: alerts"); - expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.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-js", "account", "add", "--json"], { + await program.parseAsync(["matrix", "account", "add", "--json"], { from: "user", }); @@ -300,7 +300,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" }); + await program.parseAsync(["matrix", "verify", "bootstrap", "--json"], { from: "user" }); expect(process.exitCode).toBe(0); }); @@ -326,7 +326,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "status", "--verbose"], { from: "user" }); + await program.parseAsync(["matrix", "verify", "status", "--verbose"], { from: "user" }); expect(console.log).toHaveBeenCalledWith( `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, @@ -393,10 +393,10 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "bootstrap", "--verbose"], { + await program.parseAsync(["matrix", "verify", "bootstrap", "--verbose"], { from: "user", }); - await program.parseAsync(["matrix-js", "verify", "device", "valid-key", "--verbose"], { + await program.parseAsync(["matrix", "verify", "device", "valid-key", "--verbose"], { from: "user", }); @@ -429,7 +429,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" }); + await program.parseAsync(["matrix", "verify", "status"], { from: "user" }); expect(console.log).not.toHaveBeenCalledWith( `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, @@ -462,16 +462,16 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" }); + 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-js verify backup restore' to load it and restore old room keys.", + "- 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-js verify device '.", + "- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device '.", ); }); @@ -497,7 +497,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" }); + 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)", @@ -516,7 +516,7 @@ describe("matrix-js CLI verification commands", () => { }); const program = buildProgram(); - await program.parseAsync(["matrix-js", "verify", "backup", "status", "--verbose"], { + await program.parseAsync(["matrix", "verify", "backup", "status", "--verbose"], { from: "user", }); diff --git a/extensions/matrix-js/src/cli.ts b/extensions/matrix/src/cli.ts similarity index 93% rename from extensions/matrix-js/src/cli.ts rename to extensions/matrix/src/cli.ts index 8d7fbca4ae8..829fef21819 100644 --- a/extensions/matrix-js/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -3,7 +3,7 @@ import { formatZonedTimestamp, normalizeAccountId, type ChannelSetupInput, -} from "openclaw/plugin-sdk/matrix-js"; +} from "openclaw/plugin-sdk/matrix"; import { matrixPlugin } from "./channel.js"; import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; import { @@ -14,17 +14,17 @@ import { verifyMatrixRecoveryKey, } from "./matrix/actions/verification.js"; import { setMatrixSdkLogMode } from "./matrix/client/logging.js"; -import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js"; import { getMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; -let matrixJsCliExitScheduled = false; +let matrixCliExitScheduled = false; -function scheduleMatrixJsCliExit(): void { - if (matrixJsCliExitScheduled || process.env.VITEST) { +function scheduleMatrixCliExit(): void { + if (matrixCliExitScheduled || process.env.VITEST) { return; } - matrixJsCliExitScheduled = true; + matrixCliExitScheduled = true; // matrix-js-sdk rust crypto can leave background async work alive after command completion. setTimeout(() => { process.exit(process.exitCode ?? 0); @@ -95,7 +95,7 @@ type MatrixCliAccountAddResult = { }; }; -async function addMatrixJsAccount(params: { +async function addMatrixAccount(params: { account?: string; name?: string; avatarUrl?: string; @@ -111,7 +111,7 @@ async function addMatrixJsAccount(params: { const cfg = runtime.config.loadConfig() as CoreConfig; const setup = matrixPlugin.setup; if (!setup?.applyAccountConfig) { - throw new Error("Matrix-js account setup is unavailable."); + throw new Error("Matrix account setup is unavailable."); } const input: ChannelSetupInput & { avatarUrl?: string } = { @@ -194,7 +194,7 @@ async function addMatrixJsAccount(params: { return { accountId, - configPath: `channels.matrix-js.accounts.${accountId}`, + configPath: resolveMatrixConfigPath(updated, accountId), useEnv: input.useEnv === true, profile, }; @@ -213,7 +213,7 @@ type MatrixCliProfileSetResult = { configPath: string; }; -async function setMatrixJsProfile(params: { +async function setMatrixProfile(params: { account?: string; name?: string; avatarUrl?: string; @@ -252,7 +252,7 @@ async function setMatrixJsProfile(params: { resolvedAvatarUrl: synced.resolvedAvatarUrl, convertedAvatarFromHttp: synced.convertedAvatarFromHttp, }, - configPath: `channels.matrix-js.accounts.${accountId}`, + configPath: resolveMatrixConfigPath(updated, accountId), }; } @@ -290,7 +290,7 @@ async function runMatrixCliCommand( } markCliFailure(); } finally { - scheduleMatrixJsCliExit(); + scheduleMatrixCliExit(); } } @@ -477,10 +477,10 @@ function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[ const backupIssue = resolveBackupIssue(backup); const nextSteps = new Set(); if (!status.verified) { - nextSteps.add("Run 'openclaw matrix-js verify device ' to verify this device."); + nextSteps.add("Run 'openclaw matrix verify device ' to verify this device."); } if (backupIssue.code === "missing-server-backup") { - nextSteps.add("Run 'openclaw matrix-js verify bootstrap' to create a room key backup."); + nextSteps.add("Run 'openclaw matrix verify bootstrap' to create a room key backup."); } else if ( backupIssue.code === "key-load-failed" || backupIssue.code === "key-not-loaded" || @@ -488,24 +488,24 @@ function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[ ) { if (status.recoveryKeyStored) { nextSteps.add( - "Backup key is not loaded on this device. Run 'openclaw matrix-js verify backup restore' to load it and restore old room keys.", + "Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.", ); } else { nextSteps.add( - "Store a recovery key with 'openclaw matrix-js verify device ', then run 'openclaw matrix-js verify backup restore'.", + "Store a recovery key with 'openclaw matrix verify device ', then run 'openclaw matrix verify backup restore'.", ); } } else if (backupIssue.code === "key-mismatch") { nextSteps.add( - "Backup key mismatch on this device. Re-run 'openclaw matrix-js verify device ' with the matching recovery key.", + "Backup key mismatch on this device. Re-run 'openclaw matrix verify device ' with the matching recovery key.", ); } else if (backupIssue.code === "untrusted-signature") { nextSteps.add( - "Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device '.", + "Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device '.", ); } else if (backupIssue.code === "indeterminate") { nextSteps.add( - "Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.", + "Run 'openclaw matrix verify status --verbose' to inspect backup trust diagnostics.", ); } if (status.pendingVerifications > 0) { @@ -545,17 +545,17 @@ function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = printVerificationGuidance(status); } -export function registerMatrixJsCli(params: { program: Command }): void { +export function registerMatrixCli(params: { program: Command }): void { const root = params.program - .command("matrix-js") - .description("Matrix-js channel utilities") - .addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix-js\n"); + .command("matrix") + .description("Matrix channel utilities") + .addHelpText("after", () => "\nDocs: https://docs.openclaw.ai/channels/matrix\n"); - const account = root.command("account").description("Manage matrix-js channel accounts"); + const account = root.command("account").description("Manage matrix channel accounts"); account .command("add") - .description("Add or update a matrix-js account (wrapper around channel setup)") + .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)") @@ -590,7 +590,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { verbose: options.verbose === true, json: options.json === true, run: async () => - await addMatrixJsAccount({ + await addMatrixAccount({ account: options.account, name: options.name, avatarUrl: options.avatarUrl, @@ -603,7 +603,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { useEnv: options.useEnv === true, }), onText: (result) => { - console.log(`Saved matrix-js account: ${result.accountId}`); + 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"}`, @@ -620,7 +620,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { } } } - const bindHint = `openclaw agents bind --agent --bind matrix-js:${result.accountId}`; + const bindHint = `openclaw agents bind --agent --bind matrix:${result.accountId}`; console.log(`Bind this account to an agent: ${bindHint}`); }, errorPrefix: "Account setup failed", @@ -628,7 +628,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { }, ); - const profile = root.command("profile").description("Manage Matrix-js bot profile"); + const profile = root.command("profile").description("Manage Matrix bot profile"); profile .command("set") @@ -650,7 +650,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { verbose: options.verbose === true, json: options.json === true, run: async () => - await setMatrixJsProfile({ + await setMatrixProfile({ account: options.account, name: options.name, avatarUrl: options.avatarUrl, @@ -674,7 +674,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { verify .command("status") - .description("Check Matrix-js device verification 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") @@ -775,7 +775,7 @@ export function registerMatrixJsCli(params: { program: Command }): void { verify .command("bootstrap") - .description("Bootstrap Matrix-js cross-signing and device verification state") + .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") diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index a95d2fbda96..e9b03d60a1b 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -15,6 +15,17 @@ const matrixActionSchema = z pins: z.boolean().optional(), memberInfo: z.boolean().optional(), channelInfo: z.boolean().optional(), + verification: z.boolean().optional(), + }) + .optional(); + +const matrixThreadBindingsSchema = z + .object({ + enabled: z.boolean().optional(), + idleHours: z.number().nonnegative().optional(), + maxAgeHours: z.number().nonnegative().optional(), + spawnSubagentSessions: z.boolean().optional(), + spawnAcpSessions: z.boolean().optional(), }) .optional(); @@ -41,7 +52,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 +64,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, @@ -59,4 +80,5 @@ export const MatrixConfigSchema = z.object({ groups: z.object({}).catchall(matrixRoomSchema).optional(), rooms: z.object({}).catchall(matrixRoomSchema).optional(), actions: matrixActionSchema, + register: z.boolean().optional(), }); diff --git a/extensions/matrix/src/directory-live.test.ts b/extensions/matrix/src/directory-live.test.ts index bc0b1202005..d499574bc8d 100644 --- a/extensions/matrix/src/directory-live.test.ts +++ b/extensions/matrix/src/directory-live.test.ts @@ -71,15 +71,4 @@ describe("matrix directory live", () => { expect(result).toEqual([]); expect(resolveMatrixAuth).not.toHaveBeenCalled(); }); - - it("preserves original casing for room IDs without :server suffix", async () => { - const mixedCaseId = "!EonMPPbOuhntHEHgZ2dnBO-c_EglMaXlIh2kdo8cgiA"; - const result = await listMatrixDirectoryGroupsLive({ - cfg, - query: mixedCaseId, - }); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(mixedCaseId); - }); }); diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts index b915915fdcd..93b320a7a86 100644 --- a/extensions/matrix/src/directory-live.ts +++ b/extensions/matrix/src/directory-live.ts @@ -174,8 +174,7 @@ export async function listMatrixDirectoryGroupsLive( } if (query.startsWith("!")) { - const originalId = params.query?.trim() ?? query; - return [createGroupDirectoryEntry({ id: originalId, name: originalId })]; + return [createGroupDirectoryEntry({ id: query, name: query })]; } const joined = await fetchMatrixJson({ diff --git a/extensions/matrix-js/src/matrix/account-config.ts b/extensions/matrix/src/matrix/account-config.ts similarity index 96% rename from extensions/matrix-js/src/matrix/account-config.ts rename to extensions/matrix/src/matrix/account-config.ts index e7ef49807ea..acc9a339c22 100644 --- a/extensions/matrix-js/src/matrix/account-config.ts +++ b/extensions/matrix/src/matrix/account-config.ts @@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "../types.js"; export function resolveMatrixBaseConfig(cfg: CoreConfig): MatrixConfig { - return cfg.channels?.["matrix-js"] ?? {}; + return cfg.channels?.matrix ?? {}; } export function resolveMatrixAccountsMap( diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 56319b78b3a..d453684756c 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CoreConfig } from "../types.js"; -import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./accounts.js"; +import { resolveMatrixAccount } from "./accounts.js"; vi.mock("./credentials.js", () => ({ loadMatrixCredentials: () => null, @@ -80,52 +80,3 @@ describe("resolveMatrixAccount", () => { expect(account.configured).toBe(true); }); }); - -describe("resolveDefaultMatrixAccountId", () => { - it("prefers channels.matrix.defaultAccount when it matches a configured account", () => { - const cfg: CoreConfig = { - channels: { - matrix: { - defaultAccount: "alerts", - accounts: { - default: { homeserver: "https://matrix.example.org", accessToken: "tok-default" }, - alerts: { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" }, - }, - }, - }, - }; - - expect(resolveDefaultMatrixAccountId(cfg)).toBe("alerts"); - }); - - it("normalizes channels.matrix.defaultAccount before lookup", () => { - const cfg: CoreConfig = { - channels: { - matrix: { - defaultAccount: "Team Alerts", - accounts: { - "team-alerts": { homeserver: "https://matrix.example.org", accessToken: "tok-alerts" }, - }, - }, - }, - }; - - expect(resolveDefaultMatrixAccountId(cfg)).toBe("team-alerts"); - }); - - it("falls back when channels.matrix.defaultAccount is not configured", () => { - 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" }, - }, - }, - }, - }; - - expect(resolveDefaultMatrixAccountId(cfg)).toBe("default"); - }); -}); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 52fba376200..cd9d29bccb9 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -1,7 +1,15 @@ -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { createAccountListHelpers } from "openclaw/plugin-sdk/matrix"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-id"; import { hasConfiguredSecretInput } from "../secret-input.js"; import type { CoreConfig, MatrixConfig } from "../types.js"; +import { + findMatrixAccountConfig, + resolveMatrixAccountsMap, + resolveMatrixBaseConfig, +} from "./account-config.js"; import { resolveMatrixConfigForAccount } from "./client.js"; import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; @@ -18,7 +26,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 +39,44 @@ export type ResolvedMatrixAccount = { config: MatrixConfig; }; -const { - listAccountIds: listMatrixAccountIds, - resolveDefaultAccountId: resolveDefaultMatrixAccountId, -} = createAccountListHelpers("matrix", { normalizeAccountId }); -export { listMatrixAccountIds, resolveDefaultMatrixAccountId }; +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = resolveMatrixAccountsMap(cfg); + if (Object.keys(accounts).length === 0) { + return []; + } + // Normalize and de-duplicate keys so listing and resolution use the same semantics + return [ + ...new Set( + Object.keys(accounts) + .filter(Boolean) + .map((id) => normalizeAccountId(id)), + ), + ]; +} + +export function listMatrixAccountIds(cfg: CoreConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + // Fall back to default if no accounts configured (legacy top-level config) + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { + const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount); + const ids = listMatrixAccountIds(cfg); + if (configuredDefault && ids.includes(configuredDefault)) { + return configuredDefault; + } + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? 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; + return findMatrixAccountConfig(cfg, accountId); } export function resolveMatrixAccount(params: { @@ -62,7 +84,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,7 +119,7 @@ export function resolveMatrixAccountConfig(params: { accountId?: string | null; }): MatrixConfig { const accountId = normalizeAccountId(params.accountId); - const matrixBase = params.cfg.channels?.matrix ?? {}; + const matrixBase = resolveMatrixBaseConfig(params.cfg); const accountConfig = resolveAccountConfig(params.cfg, accountId); if (!accountConfig) { return matrixBase; diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 34d24b6dd39..b6661351864 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -9,7 +9,28 @@ 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, + restoreMatrixRoomKeyBackup, + scanMatrixVerificationQr, + startMatrixVerification, + verifyMatrixRecoveryKey, +} from "./actions/verification.js"; export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix-js/src/matrix/actions/client.test.ts b/extensions/matrix/src/matrix/actions/client.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/actions/client.test.ts rename to extensions/matrix/src/matrix/actions/client.test.ts diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts index f422e09a964..a3981be0520 100644 --- a/extensions/matrix/src/matrix/actions/client.ts +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -1,9 +1,7 @@ -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 { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; export function ensureNodeRuntime() { @@ -19,29 +17,54 @@ export async function resolveActionClient( 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); + const active = getActiveMatrixClient(opts.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, + accountId: opts.accountId, }); - const client = await createPreparedMatrixClient({ - auth, - timeoutMs: opts.timeoutMs, - accountId, + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + accountId: opts.accountId, + autoBootstrapCrypto: false, }); + await client.prepareForOneOff(); return { client, stopOnDone: true }; } + +export type MatrixActionClientStopMode = "stop" | "persist"; + +export async function stopActionClient( + resolved: MatrixActionClient, + mode: MatrixActionClientStopMode = "stop", +): Promise { + if (!resolved.stopOnDone) { + return; + } + if (mode === "persist") { + await resolved.client.stopAndPersist(); + return; + } + resolved.client.stop(); +} + +export async function withResolvedActionClient( + opts: MatrixActionClientOpts, + run: (client: MatrixActionClient["client"]) => Promise, + mode: MatrixActionClientStopMode = "stop", +): Promise { + const resolved = await resolveActionClient(opts); + try { + return await run(resolved.client); + } finally { + await stopActionClient(resolved, mode); + } +} diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index c32053a0e4f..4cbe913c0d3 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { resolveMatrixActionLimit } from "./limits.js"; import { summarizeMatrixRawEvent } from "./summary.js"; import { @@ -25,6 +25,7 @@ export async function sendMatrixMessage( mediaUrl: opts.mediaUrl, replyToId: opts.replyToId, threadId: opts.threadId, + accountId: opts.accountId ?? undefined, client: opts.client, timeoutMs: opts.timeoutMs, }); @@ -40,8 +41,7 @@ export async function editMatrixMessage( if (!trimmed) { throw new Error("Matrix edit requires content"); } - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const newContent = { msgtype: MsgType.Text, @@ -58,11 +58,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 +66,10 @@ export async function deleteMatrixMessage( messageId: string, opts: MatrixActionClientOpts & { reason?: string } = {}, ) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); await client.redactEvent(resolvedRoom, messageId, opts.reason); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } export async function readMatrixMessages( @@ -93,13 +84,12 @@ export async function readMatrixMessages( nextBatch?: string | null; prevBatch?: string | null; }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); const limit = resolveMatrixActionLimit(opts.limit, 20); const token = opts.before?.trim() || opts.after?.trim() || undefined; const dir = opts.after ? "f" : "b"; - // @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`, @@ -118,9 +108,5 @@ export async function readMatrixMessages( 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..ca5ca4a8524 100644 --- a/extensions/matrix/src/matrix/actions/pins.ts +++ b/extensions/matrix/src/matrix/actions/pins.ts @@ -1,5 +1,5 @@ import { resolveMatrixRoomId } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } from "./client.js"; import { fetchEventSummary, readPinnedEvents } from "./summary.js"; import { EventType, @@ -16,15 +16,10 @@ async function withResolvedPinRoom( opts: MatrixActionClientOpts, run: (client: ActionClient, resolvedRoom: string) => Promise, ): Promise { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); return await run(client, resolvedRoom); - } finally { - if (stopOnDone) { - client.stop(); - } - } + }); } async function updateMatrixPins( diff --git a/extensions/matrix-js/src/matrix/actions/polls.ts b/extensions/matrix/src/matrix/actions/polls.ts similarity index 100% rename from extensions/matrix-js/src/matrix/actions/polls.ts rename to extensions/matrix/src/matrix/actions/polls.ts diff --git a/extensions/matrix-js/src/matrix/actions/profile.ts b/extensions/matrix/src/matrix/actions/profile.ts similarity index 100% rename from extensions/matrix-js/src/matrix/actions/profile.ts rename to extensions/matrix/src/matrix/actions/profile.ts 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..3be838198f9 100644 --- a/extensions/matrix/src/matrix/actions/reactions.ts +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -1,30 +1,30 @@ +import { + buildMatrixReactionRelationsPath, + selectOwnMatrixReactionEventIds, + summarizeMatrixReactionEvents, +} from "../reaction-common.js"; import { resolveMatrixRoomId } from "../send.js"; -import { resolveActionClient } from "./client.js"; +import { withResolvedActionClient } 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 +32,12 @@ export async function listMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { limit?: number } = {}, ): Promise { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); 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 +45,18 @@ export async function removeMatrixReactions( messageId: string, opts: MatrixActionClientOpts & { emoji?: string } = {}, ): Promise<{ removed: number }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const chunk = await listReactionEvents(client, resolvedRoom, messageId, 200); + 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.ts b/extensions/matrix/src/matrix/actions/room.ts index e1770c7bc8d..8180a3dc253 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 } 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,12 @@ 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 { + return await withResolvedActionClient(opts, async (client) => { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - // @vector-im/matrix-bot-sdk uses getRoomState for state events let name: string | null = null; let topic: string | null = null; let canonicalAlias: string | null = null; @@ -43,21 +34,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 +68,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.ts b/extensions/matrix/src/matrix/actions/summary.ts index 061829b0de5..5fd81401183 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "../sdk.js"; import { EventType, type MatrixMessageSummary, diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 96694f4c743..57092550d30 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -1,4 +1,11 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +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 +13,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 +34,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,17 +44,6 @@ 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; timeoutMs?: number; @@ -73,12 +63,6 @@ export type MatrixMessageSummary = { }; }; -export type MatrixReactionSummary = { - key: string; - count: number; - users: string[]; -}; - export type MatrixActionClient = { client: MatrixClient; stopOnDone: boolean; diff --git a/extensions/matrix-js/src/matrix/actions/verification.ts b/extensions/matrix/src/matrix/actions/verification.ts similarity index 98% rename from extensions/matrix-js/src/matrix/actions/verification.ts rename to extensions/matrix/src/matrix/actions/verification.ts index f22185194e8..64e7118118a 100644 --- a/extensions/matrix-js/src/matrix/actions/verification.ts +++ b/extensions/matrix/src/matrix/actions/verification.ts @@ -5,9 +5,7 @@ function requireCrypto( client: import("../sdk.js").MatrixClient, ): NonNullable { if (!client.crypto) { - throw new Error( - "Matrix encryption is not available (enable channels.matrix-js.encryption=true)", - ); + throw new Error("Matrix encryption is not available (enable channels.matrix.encryption=true)"); } return client.crypto; } 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/client-bootstrap.ts b/extensions/matrix/src/matrix/client-bootstrap.ts index 9b8d4b7d7a2..66512291945 100644 --- a/extensions/matrix/src/matrix/client-bootstrap.ts +++ b/extensions/matrix/src/matrix/client-bootstrap.ts @@ -1,6 +1,4 @@ -import { createMatrixClient } from "./client/create-client.js"; -import { startMatrixClientWithGrace } from "./client/startup.js"; -import { getMatrixLogService } from "./sdk-runtime.js"; +import { createMatrixClient } from "./client.js"; type MatrixClientBootstrapAuth = { homeserver: string; @@ -36,12 +34,6 @@ export async function createPreparedMatrixClient(opts: { // Ignore crypto prep failures for one-off requests. } } - await startMatrixClientWithGrace({ - client, - onError: (err: unknown) => { - const LogService = getMatrixLogService(); - LogService.error("MatrixClientBootstrap", "client.start() error:", err); - }, - }); + await client.start(); return client; } diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 69de112dbd5..2ced0631084 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -1,6 +1,17 @@ -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 { resolveMatrixAuth, resolveMatrixConfig, resolveMatrixConfigForAccount } from "./client.js"; +import * as credentialsModule from "./credentials.js"; +import * as sdkModule from "./sdk.js"; + +const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn()); + +vi.mock("./credentials.js", () => ({ + loadMatrixCredentials: vi.fn(() => null), + saveMatrixCredentials: saveMatrixCredentialsMock, + credentialsMatchConfig: vi.fn(() => false), + touchMatrixCredentials: vi.fn(), +})); describe("resolveMatrixConfig", () => { it("prefers config over env", () => { @@ -29,6 +40,7 @@ describe("resolveMatrixConfig", () => { userId: "@cfg:example.org", accessToken: "cfg-token", password: "cfg-pass", + deviceId: undefined, deviceName: "CfgDevice", initialSyncLimit: 5, encryption: false, @@ -42,6 +54,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 +62,253 @@ 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"); + }); +}); + +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", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenCalledWith( + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + }), + ); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + }), + expect.any(Object), + undefined, + ); + }); + + it("surfaces password login errors when account credentials are invalid", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest"); + doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password")); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + }, + }, + } as CoreConfig; + + await expect( + resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }), + ).rejects.toThrow("Invalid username or password"); + + expect(doRequestSpy).toHaveBeenCalledWith( + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + }), + ); + expect(saveMatrixCredentialsMock).not.toHaveBeenCalled(); + }); + + it("uses cached matching credentials when access token is not configured", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "cached-token", + deviceId: "CACHEDDEVICE", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "cached-token", + deviceId: "CACHEDDEVICE", + }); + expect(saveMatrixCredentialsMock).not.toHaveBeenCalled(); + }); + + it("falls back to config deviceId when cached credentials are missing it", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth.deviceId).toBe("DEVICE123"); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + }), + expect.any(Object), + undefined, + ); + }); + + it("resolves missing whoami identity fields for token auth", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); + + it("uses config deviceId with cached credentials when token is loaded from cache", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); }); diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 53abe1c3d5f..82fe95d0fed 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -1,8 +1,11 @@ export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; export { isBunRuntime } from "./client/runtime.js"; export { + getMatrixScopedEnvVarNames, + hasReadyMatrixEnvAuth, resolveMatrixConfig, resolveMatrixConfigForAccount, + resolveScopedMatrixEnvConfig, resolveMatrixAuth, } from "./client/config.js"; export { createMatrixClient } from "./client/create-client.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 2867af33f03..197e182e0aa 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,12 +1,9 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; -import { - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "../../secret-input.js"; +import { normalizeResolvedSecretInputString } from "../../secret-input.js"; import type { CoreConfig } from "../../types.js"; -import { loadMatrixSdk } from "../sdk-runtime.js"; +import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js"; +import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -14,65 +11,112 @@ 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; +}; + +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, + }; } -/** - * 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( +function resolveMatrixEnvAccountToken(accountId: string): string { + return normalizeAccountId(accountId) + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toUpperCase(); +} + +export function getMatrixScopedEnvVarNames(accountId: string): { + homeserver: string; + userId: string; + accessToken: string; + password: string; + deviceId: string; + deviceName: string; +} { + const token = resolveMatrixEnvAccountToken(accountId); + return { + homeserver: `MATRIX_${token}_HOMESERVER`, + userId: `MATRIX_${token}_USER_ID`, + accessToken: `MATRIX_${token}_ACCESS_TOKEN`, + password: `MATRIX_${token}_PASSWORD`, + deviceId: `MATRIX_${token}_DEVICE_ID`, + deviceName: `MATRIX_${token}_DEVICE_NAME`, + }; +} + +export function resolveScopedMatrixEnvConfig( + accountId: string, + env: NodeJS.ProcessEnv = process.env, +): MatrixEnvConfig { + const keys = getMatrixScopedEnvVarNames(accountId); + return { + homeserver: clean(env[keys.homeserver], 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, + }; +} + +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 resolveMatrixConfig( 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; - - // 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; - } - } - } - - // 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 matrix = resolveMatrixBaseConfig(cfg); + const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, env); + const globalEnv = resolveGlobalMatrixEnvConfig(env); const homeserver = clean(matrix.homeserver, "channels.matrix.homeserver") || - clean(env.MATRIX_HOMESERVER, "MATRIX_HOMESERVER"); + defaultScopedEnv.homeserver || + globalEnv.homeserver; const userId = - clean(matrix.userId, "channels.matrix.userId") || clean(env.MATRIX_USER_ID, "MATRIX_USER_ID"); + clean(matrix.userId, "channels.matrix.userId") || defaultScopedEnv.userId || globalEnv.userId; const accessToken = clean(matrix.accessToken, "channels.matrix.accessToken") || - clean(env.MATRIX_ACCESS_TOKEN, "MATRIX_ACCESS_TOKEN") || + defaultScopedEnv.accessToken || + globalEnv.accessToken || undefined; const password = clean(matrix.password, "channels.matrix.password") || - clean(env.MATRIX_PASSWORD, "MATRIX_PASSWORD") || + defaultScopedEnv.password || + globalEnv.password || + undefined; + const deviceId = + clean(matrix.deviceId, "channels.matrix.deviceId") || + defaultScopedEnv.deviceId || + globalEnv.deviceId || undefined; const deviceName = clean(matrix.deviceName, "channels.matrix.deviceName") || - clean(env.MATRIX_DEVICE_NAME, "MATRIX_DEVICE_NAME") || + defaultScopedEnv.deviceName || + globalEnv.deviceName || undefined; const initialSyncLimit = typeof matrix.initialSyncLimit === "number" @@ -84,20 +128,106 @@ export function resolveMatrixConfigForAccount( userId, accessToken, password, + deviceId, deviceName, 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 accountHomeserver = clean( + account.homeserver, + `channels.matrix.accounts.${normalizedAccountId}.homeserver`, + ); + const accountUserId = clean( + account.userId, + `channels.matrix.accounts.${normalizedAccountId}.userId`, + ); + const accountAccessToken = clean( + account.accessToken, + `channels.matrix.accounts.${normalizedAccountId}.accessToken`, + ); + const accountPassword = clean( + account.password, + `channels.matrix.accounts.${normalizedAccountId}.password`, + ); + const accountDeviceId = clean( + account.deviceId, + `channels.matrix.accounts.${normalizedAccountId}.deviceId`, + ); + const accountDeviceName = clean( + account.deviceName, + `channels.matrix.accounts.${normalizedAccountId}.deviceName`, + ); + + const homeserver = + accountHomeserver || + scopedEnv.homeserver || + clean(matrix.homeserver, "channels.matrix.homeserver") || + globalEnv.homeserver; + const userId = + accountUserId || + scopedEnv.userId || + clean(matrix.userId, "channels.matrix.userId") || + globalEnv.userId; + const accessToken = + accountAccessToken || + scopedEnv.accessToken || + clean(matrix.accessToken, "channels.matrix.accessToken") || + globalEnv.accessToken || + undefined; + const password = + accountPassword || + scopedEnv.password || + clean(matrix.password, "channels.matrix.password") || + globalEnv.password || + undefined; + const deviceId = + accountDeviceId || + scopedEnv.deviceId || + clean(matrix.deviceId, "channels.matrix.deviceId") || + globalEnv.deviceId || + undefined; + const deviceName = + accountDeviceName || + scopedEnv.deviceName || + clean(matrix.deviceName, "channels.matrix.deviceName") || + globalEnv.deviceName || + undefined; + + const accountInitialSyncLimit = + typeof account.initialSyncLimit === "number" + ? Math.max(0, Math.floor(account.initialSyncLimit)) + : undefined; + const initialSyncLimit = + accountInitialSyncLimit ?? + (typeof matrix.initialSyncLimit === "number" + ? Math.max(0, Math.floor(matrix.initialSyncLimit)) + : undefined); + const encryption = + typeof account.encryption === "boolean" ? account.encryption : (matrix.encryption ?? false); + + return { + homeserver, + userId, + accessToken, + password, + deviceId, + deviceName, + initialSyncLimit, + encryption, + }; } export async function resolveMatrixAuth(params?: { @@ -107,7 +237,10 @@ export async function resolveMatrixAuth(params?: { }): Promise { const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); const env = params?.env ?? process.env; - const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env); + const accountId = params?.accountId; + const resolved = accountId + ? resolveMatrixConfigForAccount(cfg, accountId, env) + : resolveMatrixConfig(cfg, env); if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } @@ -119,7 +252,6 @@ export async function resolveMatrixAuth(params?: { touchMatrixCredentials, } = await import("../credentials.js"); - const accountId = params?.accountId; const cached = loadMatrixCredentials(env, accountId); const cachedCredentials = cached && @@ -133,30 +265,56 @@ 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 whoami = (await tempClient.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { + user_id?: string; + device_id?: string; + }; + if (!userId) { + const fetchedUserId = whoami.user_id?.trim(); + if (!fetchedUserId) { + throw new Error("Matrix whoami did not return user_id"); + } + userId = fetchedUserId; + } + if (!knownDeviceId) { + knownDeviceId = whoami.device_id?.trim() || resolved.deviceId; + } + } + + const shouldRefreshCachedCredentials = + !cachedCredentials || + !hasMatchingCachedToken || + cachedCredentials.userId !== userId || + (cachedCredentials.deviceId || undefined) !== knownDeviceId; + if (shouldRefreshCachedCredentials) { + await saveMatrixCredentials( { homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, + deviceId: knownDeviceId, }, env, accountId, ); - } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { - touchMatrixCredentials(env, accountId); + } else if (hasMatchingCachedToken) { + await touchMatrixCredentials(env, accountId); } return { homeserver: resolved.homeserver, userId, accessToken: resolved.accessToken, + password: resolved.password, + deviceId: knownDeviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, @@ -164,11 +322,13 @@ export async function resolveMatrixAuth(params?: { } if (cachedCredentials) { - touchMatrixCredentials(env, accountId); + await touchMatrixCredentials(env, accountId); return { homeserver: cachedCredentials.homeserver, userId: cachedCredentials.userId, accessToken: cachedCredentials.accessToken, + password: resolved.password, + deviceId: cachedCredentials.deviceId || resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, @@ -185,36 +345,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(resolved.homeserver, ""); + const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, { + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + device_id: resolved.deviceId, + initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", + })) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; const accessToken = login.access_token?.trim(); if (!accessToken) { @@ -225,17 +369,19 @@ export async function resolveMatrixAuth(params?: { homeserver: resolved.homeserver, userId: login.user_id ?? resolved.userId, accessToken, + password: resolved.password, + deviceId: login.device_id ?? resolved.deviceId, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, encryption: resolved.encryption, }; - 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 55cf210449c..b17c91410f0 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -1,10 +1,5 @@ import fs from "node:fs"; -import type { - IStorageProvider, - ICryptoStorageProvider, - MatrixClient, -} from "@vector-im/matrix-bot-sdk"; -import { loadMatrixSdk } from "../sdk-runtime.js"; +import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { maybeMigrateLegacyStorage, @@ -12,114 +7,55 @@ 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 { - const { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, LogService } = - loadMatrixSdk(); ensureMatrixSdkLoggingConfigured(); const env = process.env; + const userId = params.userId?.trim() || "unknown"; + const matrixClientUserId = params.userId?.trim() || undefined; - // Create storage provider const storagePaths = resolveMatrixStoragePaths({ homeserver: params.homeserver, - userId: params.userId, + userId, accessToken: params.accessToken, accountId: params.accountId, env, }); - 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, + userId, accountId: params.accountId, }); - 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(params.homeserver, params.accessToken, undefined, undefined, { + userId: matrixClientUserId, + password: params.password, + deviceId: params.deviceId, + encryption: params.encryption, + localTimeoutMs: params.localTimeoutMs, + initialSyncLimit: params.initialSyncLimit, + recoveryKeyPath: storagePaths.recoveryKeyPath, + idbSnapshotPath: storagePaths.idbSnapshotPath, + cryptoDatabasePrefix, + autoBootstrapCrypto: params.autoBootstrapCrypto, + }); } diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 1f07d7ed542..b1384ddfe97 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,18 +1,20 @@ -import { loadMatrixSdk } from "../sdk-runtime.js"; +import { ConsoleLogger, LogService } 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(); + +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 +26,75 @@ 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 createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLogger { + return createMatrixJsSdkLoggerInstance(prefix); +} + +function applyMatrixSdkLogger(): void { + 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..72b708f3c93 100644 --- a/extensions/matrix/src/matrix/client/shared.test.ts +++ b/extensions/matrix/src/matrix/client/shared.test.ts @@ -1,85 +1,112 @@ -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 createMatrixClientMock = vi.hoisted(() => vi.fn()); -vi.mock("./create-client.js", () => ({ - createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), +vi.mock("./config.js", () => ({ + resolveMatrixAuth: resolveMatrixAuthMock, })); -function makeAuth(suffix: string): MatrixAuth { +vi.mock("./create-client.js", () => ({ + createMatrixClient: createMatrixClientMock, +})); + +import { + resolveSharedMatrixClient, + stopSharedClient, + stopSharedClientForAccount, +} from "./shared.js"; + +function authFor(accountId: string): MatrixAuth { return { homeserver: "https://matrix.example.org", - userId: `@bot-${suffix}:example.org`, - accessToken: `token-${suffix}`, + userId: `@${accountId}:example.org`, + accessToken: `token-${accountId}`, + password: "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(); + createMatrixClientMock.mockReset(); + }); + 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"), - }); - await vi.advanceTimersByTimeAsync(2000); - await expect(secondResolve).resolves.toBe(client); - expect(startMock).toHaveBeenCalledTimes(2); + stopSharedClientForAccount(mainAuth, "main"); + + expect(mainClient.stop).toHaveBeenCalledTimes(1); + expect(poeClient.stop).toHaveBeenCalledTimes(0); + + stopSharedClient(); + + expect(poeClient.stop).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index e12aa795d8c..fd7c76995f1 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,10 +1,8 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { CoreConfig } from "../../types.js"; -import { getMatrixLogService } from "../sdk-runtime.js"; +import type { MatrixClient } from "../sdk.js"; +import { LogService } from "../sdk/logger.js"; import { resolveMatrixAuth } from "./config.js"; import { createMatrixClient } from "./create-client.js"; -import { startMatrixClientWithGrace } from "./startup.js"; import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; import type { MatrixAuth } from "./types.js"; @@ -13,21 +11,19 @@ type SharedMatrixClientState = { key: string; started: boolean; cryptoReady: boolean; + startPromise: Promise | null; }; -// 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); return [ auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain", - normalizedAccountId || DEFAULT_ACCOUNT_KEY, + accountId ?? DEFAULT_ACCOUNT_KEY, ].join("|"); } @@ -40,8 +36,11 @@ async function createSharedMatrixClient(params: { homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, + password: params.auth.password, + deviceId: params.auth.deviceId, encryption: params.auth.encryption, localTimeoutMs: params.timeoutMs, + initialSyncLimit: params.auth.initialSyncLimit, accountId: params.accountId, }); return { @@ -49,6 +48,7 @@ async function createSharedMatrixClient(params: { key: buildSharedClientKey(params.auth, params.accountId), started: false, cryptoReady: false, + startPromise: null, }; } @@ -61,13 +61,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 +74,22 @@ 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; } } @@ -114,13 +103,16 @@ 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); + params.auth ?? + (await resolveMatrixAuth({ + cfg: params.cfg, + env: params.env, + accountId: params.accountId, + })); + const key = buildSharedClientKey(auth, params.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) { @@ -134,7 +126,6 @@ export async function resolveSharedMatrixClient( return existingState.client; } - // Check if there's a pending creation for this key const existingPromise = sharedClientPromises.get(key); if (existingPromise) { const pending = await existingPromise; @@ -149,15 +140,15 @@ export async function resolveSharedMatrixClient( return pending.client; } - // Create a new client for this account - const createPromise = createSharedMatrixClient({ + const creationPromise = createSharedMatrixClient({ auth, timeoutMs: params.timeoutMs, - accountId, + accountId: params.accountId, }); - sharedClientPromises.set(key, createPromise); + sharedClientPromises.set(key, creationPromise); + try { - const created = await createPromise; + const created = await creationPromise; sharedClientStates.set(key, created); if (shouldStart) { await ensureSharedClientStarted({ @@ -178,33 +169,25 @@ export async function waitForMatrixSync(_params: { timeoutMs?: number; abortSignal?: AbortSignal; }): Promise { - // @vector-im/matrix-bot-sdk handles sync internally in start() + // matrix-js-sdk handles sync lifecycle in start() for this integration. // This is kept for API compatibility but is essentially a no-op now } -export function stopSharedClient(key?: string): void { - if (key) { - // Stop a specific client - const state = sharedClientStates.get(key); - if (state) { - state.client.stop(); - sharedClientStates.delete(key); - } - } else { - // Stop all clients (backward compatible behavior) - for (const state of sharedClientStates.values()) { - state.client.stop(); - } - sharedClientStates.clear(); +export function stopSharedClient(): void { + for (const state of sharedClientStates.values()) { + state.client.stop(); } + sharedClientStates.clear(); + sharedClientPromises.clear(); } -/** - * 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); + const key = buildSharedClientKey(auth, accountId); + const state = sharedClientStates.get(key); + if (!state) { + return; + } + state.client.stop(); + sharedClientStates.delete(key); + sharedClientPromises.delete(key); } 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-js/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts similarity index 59% rename from extensions/matrix-js/src/matrix/client/storage.test.ts rename to extensions/matrix/src/matrix/client/storage.test.ts index 682807a4569..37d13d45afd 100644 --- a/extensions/matrix-js/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -55,46 +55,7 @@ describe("matrix client storage paths", () => { ); }); - it("migrates the nested legacy matrix-js account directory into the simplified root", () => { - const stateDir = setupStateDir(); - const storagePaths = resolveMatrixStoragePaths({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "secret-token", - accountId: "ops", - env: {}, - }); - const legacyRoot = path.join( - stateDir, - "credentials", - "matrix-js", - "accounts", - "ops", - "matrix.example.org__bot_example.org", - storagePaths.tokenHash, - ); - fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true }); - fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), "{}"); - fs.writeFileSync(path.join(legacyRoot, "recovery-key.json"), '{"key":"abc"}'); - fs.writeFileSync(path.join(legacyRoot, "crypto-idb-snapshot.json"), "[]"); - - maybeMigrateLegacyStorage({ - storagePaths, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "secret-token", - accountId: "ops", - env: {}, - }); - - expect(fs.existsSync(legacyRoot)).toBe(false); - expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe("{}"); - expect(fs.readFileSync(storagePaths.recoveryKeyPath, "utf8")).toBe('{"key":"abc"}'); - expect(fs.readFileSync(storagePaths.idbSnapshotPath, "utf8")).toBe("[]"); - expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true); - }); - - it("falls back to migrating the older flat matrix-js storage layout", () => { + it("falls back to migrating the older flat matrix storage layout", () => { const stateDir = setupStateDir(); const storagePaths = resolveMatrixStoragePaths({ homeserver: "https://matrix.example.org", @@ -102,15 +63,12 @@ describe("matrix client storage paths", () => { accessToken: "secret-token", env: {}, }); - const legacyRoot = path.join(stateDir, "credentials", "matrix-js"); + const legacyRoot = path.join(stateDir, "matrix"); fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true }); fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), '{"legacy":true}'); maybeMigrateLegacyStorage({ storagePaths, - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "secret-token", env: {}, }); diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 32f9768c68c..9d1c8ebf179 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -1,47 +1,23 @@ -import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { + resolveMatrixAccountStorageRoot, + resolveMatrixLegacyFlatStoragePaths, +} from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; import type { MatrixStoragePaths } from "./types.js"; export const DEFAULT_ACCOUNT_KEY = "default"; const STORAGE_META_FILENAME = "storage-meta.json"; -function sanitizePathSegment(value: string): string { - const cleaned = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return cleaned || "unknown"; -} - -function resolveHomeserverKey(homeserver: string): string { - try { - const url = new URL(homeserver); - if (url.host) { - return sanitizePathSegment(url.host); - } - } catch { - // fall through - } - return sanitizePathSegment(homeserver); -} - -function hashAccessToken(accessToken: string): string { - return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); -} - function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { storagePath: string; cryptoPath: string; } { const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return { - storagePath: path.join(stateDir, "matrix", "bot-storage.json"), - cryptoPath: path.join(stateDir, "matrix", "crypto"), - }; + const legacy = resolveMatrixLegacyFlatStoragePaths(stateDir); + return { storagePath: legacy.storagePath, cryptoPath: legacy.cryptoPath }; } export function resolveMatrixStoragePaths(params: { @@ -53,23 +29,20 @@ export function resolveMatrixStoragePaths(params: { }): 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 { rootDir, accountKey, tokenHash } = resolveMatrixAccountStorageRoot({ stateDir, - "matrix", - "accounts", - accountKey, - `${serverKey}__${userKey}`, - tokenHash, - ); + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + accountId: params.accountId, + }); return { rootDir, storagePath: path.join(rootDir, "bot-storage.json"), cryptoPath: path.join(rootDir, "crypto"), metaPath: path.join(rootDir, STORAGE_META_FILENAME), + recoveryKeyPath: path.join(rootDir, "recovery-key.json"), + idbSnapshotPath: path.join(rootDir, "crypto-idb-snapshot.json"), accountKey, tokenHash, }; @@ -79,18 +52,18 @@ export function maybeMigrateLegacyStorage(params: { storagePaths: MatrixStoragePaths; env?: NodeJS.ProcessEnv; }): void { + const hasNewStorage = + fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath); + if (hasNewStorage) { + return; + } + 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) { - return; - } fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); if (hasLegacyStorage) { diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts index ec1b3002bc7..4a6bac48a40 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; @@ -19,6 +20,8 @@ export type MatrixAuth = { homeserver: string; userId: string; accessToken: string; + password?: string; + deviceId?: string; deviceName?: string; initialSyncLimit?: number; encryption?: boolean; @@ -29,6 +32,8 @@ export type MatrixStoragePaths = { storagePath: string; cryptoPath: string; metaPath: string; + recoveryKeyPath: string; + idbSnapshotPath: string; accountKey: string; tokenHash: string; }; diff --git a/extensions/matrix-js/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts similarity index 77% rename from extensions/matrix-js/src/matrix/config-update.test.ts rename to extensions/matrix/src/matrix/config-update.test.ts index 3be4a6dac89..515eb82c4d6 100644 --- a/extensions/matrix-js/src/matrix/config-update.test.ts +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -6,7 +6,7 @@ describe("updateMatrixAccountConfig", () => { it("supports explicit null clears and boolean false values", () => { const cfg = { channels: { - "matrix-js": { + matrix: { accounts: { default: { homeserver: "https://matrix.example.org", @@ -27,12 +27,12 @@ describe("updateMatrixAccountConfig", () => { encryption: false, }); - expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ + expect(updated.channels?.["matrix"]?.accounts?.default).toMatchObject({ accessToken: "new-token", encryption: false, }); - expect(updated.channels?.["matrix-js"]?.accounts?.default?.password).toBeUndefined(); - expect(updated.channels?.["matrix-js"]?.accounts?.default?.userId).toBeUndefined(); + 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", () => { @@ -41,7 +41,7 @@ describe("updateMatrixAccountConfig", () => { homeserver: "https://matrix.example.org", }); - expect(updated.channels?.["matrix-js"]?.accounts?.["main-bot"]).toMatchObject({ + expect(updated.channels?.["matrix"]?.accounts?.["main-bot"]).toMatchObject({ name: "Main Bot", homeserver: "https://matrix.example.org", enabled: true, diff --git a/extensions/matrix-js/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts similarity index 66% rename from extensions/matrix-js/src/matrix/config-update.ts rename to extensions/matrix/src/matrix/config-update.ts index 1082eda5a6b..3c17e4332df 100644 --- a/extensions/matrix-js/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -1,4 +1,5 @@ -import { normalizeAccountId } from "openclaw/plugin-sdk/matrix-js"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import { normalizeAccountId } from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixConfig } from "../types.js"; export type MatrixAccountPatch = { @@ -34,14 +35,32 @@ function applyNullableStringField( target[key] = trimmed; } +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 updateMatrixAccountConfig( cfg: CoreConfig, accountId: string, patch: MatrixAccountPatch, ): CoreConfig { - const matrix = cfg.channels?.["matrix-js"] ?? {}; + const matrix = cfg.channels?.matrix ?? {}; const normalizedAccountId = normalizeAccountId(accountId); - const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? {}) as MatrixConfig; + const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? + (normalizedAccountId === DEFAULT_ACCOUNT_ID ? matrix : {})) as MatrixConfig; const nextAccount: Record = { ...existingAccount }; if (patch.name !== undefined) { @@ -85,11 +104,27 @@ export function updateMatrixAccountConfig( } } + 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-js": { + matrix: { ...matrix, enabled: true, accounts: { diff --git a/extensions/matrix-js/src/matrix/credentials.test.ts b/extensions/matrix/src/matrix/credentials.test.ts similarity index 96% rename from extensions/matrix-js/src/matrix/credentials.test.ts rename to extensions/matrix/src/matrix/credentials.test.ts index 08893616337..7246ce4db61 100644 --- a/extensions/matrix-js/src/matrix/credentials.test.ts +++ b/extensions/matrix/src/matrix/credentials.test.ts @@ -80,9 +80,9 @@ describe("matrix credentials storage", () => { } }); - it("migrates legacy matrix-js credential files on read", async () => { + it("migrates legacy matrix credential files on read", async () => { const stateDir = setupStateDir(); - const legacyPath = path.join(stateDir, "credentials", "matrix-js", "credentials.json"); + const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json"); fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); fs.writeFileSync( legacyPath, @@ -104,7 +104,7 @@ describe("matrix credentials storage", () => { it("clears both current and legacy credential paths", () => { const stateDir = setupStateDir(); const currentPath = path.join(stateDir, "credentials", "matrix", "credentials.json"); - const legacyPath = path.join(stateDir, "credentials", "matrix-js", "credentials.json"); + 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, "{}"); diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index 7da620324d7..a104d0f13b6 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -1,7 +1,10 @@ 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 { + resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir, + resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, + writeJsonFileAtomically, +} from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../runtime.js"; export type MatrixStoredCredentials = { @@ -13,30 +16,20 @@ export type MatrixStoredCredentials = { lastUsedAt?: string; }; -function credentialsFilename(accountId?: string | null): string { - const normalized = normalizeAccountId(accountId); - if (normalized === DEFAULT_ACCOUNT_ID) { - return "credentials.json"; - } - // normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe. - // Different raw IDs that normalize to the same value are the same logical account. - return `credentials-${normalized}.json`; -} - export function resolveMatrixCredentialsDir( env: NodeJS.ProcessEnv = process.env, stateDir?: string, ): string { const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return path.join(resolvedStateDir, "credentials", "matrix"); + 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 = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId }); } export function loadMatrixCredentials( @@ -63,14 +56,11 @@ export function loadMatrixCredentials( } } -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); @@ -82,13 +72,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; @@ -96,7 +86,7 @@ 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( diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index 25c0ead4c48..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 "openclaw/plugin-sdk/matrix"; +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-js/src/matrix/monitor/ack-config.test.ts b/extensions/matrix/src/matrix/monitor/ack-config.test.ts similarity index 96% rename from extensions/matrix-js/src/matrix/monitor/ack-config.test.ts rename to extensions/matrix/src/matrix/monitor/ack-config.test.ts index 3ab563a1cfb..afba5890d33 100644 --- a/extensions/matrix-js/src/matrix/monitor/ack-config.test.ts +++ b/extensions/matrix/src/matrix/monitor/ack-config.test.ts @@ -11,7 +11,7 @@ describe("resolveMatrixAckReactionConfig", () => { ackReactionScope: "all", }, channels: { - "matrix-js": { + matrix: { ackReaction: "✅", ackReactionScope: "group-all", accounts: { @@ -41,7 +41,7 @@ describe("resolveMatrixAckReactionConfig", () => { ackReactionScope: "all", }, channels: { - "matrix-js": { + matrix: { ackReaction: "✅", }, }, diff --git a/extensions/matrix-js/src/matrix/monitor/ack-config.ts b/extensions/matrix/src/matrix/monitor/ack-config.ts similarity index 64% rename from extensions/matrix-js/src/matrix/monitor/ack-config.ts rename to extensions/matrix/src/matrix/monitor/ack-config.ts index 607f6bc480d..c7d8b668f14 100644 --- a/extensions/matrix-js/src/matrix/monitor/ack-config.ts +++ b/extensions/matrix/src/matrix/monitor/ack-config.ts @@ -1,4 +1,6 @@ -import { resolveAckReaction, type OpenClawConfig } from "openclaw/plugin-sdk/matrix-js"; +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"; @@ -7,17 +9,17 @@ export function resolveMatrixAckReactionConfig(params: { agentId: string; accountId?: string | null; }): { ackReaction: string; ackReactionScope: MatrixAckReactionScope } { - const matrixConfig = params.cfg.channels?.["matrix-js"]; - const accountConfig = - params.accountId && params.accountId !== "default" - ? matrixConfig?.accounts?.[params.accountId] - : undefined; + 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-js", + channel: "matrix", accountId: params.accountId ?? undefined, }).trim(); const ackReactionScope = - accountConfig?.ackReactionScope ?? + accountConfig.ackReactionScope ?? matrixConfig?.ackReactionScope ?? params.cfg.messages?.ackReactionScope ?? "group-mentions"; diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts index 326360cade5..5d96f223874 100644 --- a/extensions/matrix/src/matrix/monitor/allowlist.ts +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -1,7 +1,6 @@ import { - compileAllowlist, normalizeStringEntries, - resolveAllowlistCandidates, + resolveAllowlistMatchByCandidates, type AllowlistMatch, } from "openclaw/plugin-sdk/matrix"; @@ -70,29 +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); - if (compiledAllowList.set.size === 0) { + const allowList = params.allowList; + if (allowList.length === 0) { return { allowed: false }; } - if (compiledAllowList.wildcard) { + 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 resolveAllowlistCandidates({ - compiledAllowlist: compiledAllowList, - candidates, - }); + return resolveAllowlistMatchByCandidates({ allowList, candidates }); } export function resolveMatrixAllowListMatches(params: { allowList: string[]; userId?: string }) { diff --git a/extensions/matrix-js/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts similarity index 90% rename from extensions/matrix-js/src/matrix/monitor/auto-join.test.ts rename to extensions/matrix/src/matrix/monitor/auto-join.test.ts index 367bb9de9c1..eb4d0220da8 100644 --- a/extensions/matrix-js/src/matrix/monitor/auto-join.test.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts @@ -1,4 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../../runtime.js"; import type { CoreConfig } from "../../types.js"; @@ -41,7 +41,7 @@ describe("registerMatrixAutoJoin", () => { const { client, getInviteHandler, joinRoom } = createClientStub(); const cfg: CoreConfig = { channels: { - "matrix-js": { + matrix: { autoJoin: "always", }, }, @@ -53,7 +53,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -71,7 +71,7 @@ describe("registerMatrixAutoJoin", () => { }); const cfg: CoreConfig = { channels: { - "matrix-js": { + matrix: { autoJoin: "allowlist", autoJoinAllowlist: ["#allowed:example.org"], }, @@ -84,7 +84,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, }); const inviteHandler = getInviteHandler(); @@ -102,7 +102,7 @@ describe("registerMatrixAutoJoin", () => { }); const cfg: CoreConfig = { channels: { - "matrix-js": { + matrix: { autoJoin: "allowlist", autoJoinAllowlist: [" #allowed:example.org "], }, @@ -115,7 +115,7 @@ describe("registerMatrixAutoJoin", () => { runtime: { log: vi.fn(), error: vi.fn(), - } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv, }); const inviteHandler = getInviteHandler(); diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 221e1df504a..72d6cd79699 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,8 +1,7 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; 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 { MatrixClient } from "../sdk.js"; export function registerMatrixAutoJoin(params: { client: MatrixClient; @@ -17,49 +16,53 @@ export function registerMatrixAutoJoin(params: { } runtime.log?.(message); }; - const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; - const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; + const autoJoin = cfg.channels?.["matrix"]?.autoJoin ?? "always"; + const autoJoinAllowlist = new Set( + (cfg.channels?.["matrix"]?.autoJoinAllowlist ?? []) + .map((entry) => String(entry).trim()) + .filter(Boolean), + ); 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 + // 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") { + let alias: string | undefined; + let altAliases: string[] = []; + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined; + altAliases = + aliasState && Array.isArray(aliasState.alt_aliases) + ? aliasState.alt_aliases + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter(Boolean) + : []; + } catch { + // Ignore errors + } - // 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.has("*") || + autoJoinAllowlist.has(roomId) || + (alias ? autoJoinAllowlist.has(alias) : false) || + altAliases.some((value) => autoJoinAllowlist.has(value)); - 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/direct.test.ts b/extensions/matrix/src/matrix/monitor/direct.test.ts index 298b3996837..5fd42f7f69d 100644 --- a/extensions/matrix/src/matrix/monitor/direct.test.ts +++ b/extensions/matrix/src/matrix/monitor/direct.test.ts @@ -1,400 +1,65 @@ import { 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; - -function createMockClient(opts: { - dmRooms?: DmMap; - membersByRoom?: Record; - stateEvents?: Record; - selfUserId?: string; +function createMockClient(params: { + isDm?: boolean; + senderDirect?: boolean; + selfDirect?: boolean; + members?: string[]; }) { - const { - dmRooms = {}, - membersByRoom = {}, - stateEvents = {}, - selfUserId = "@bot:example.org", - } = opts; - + const 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] ?? []; - }), + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + getJoinedRoomMembers: vi.fn().mockResolvedValue(members), 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; + .mockImplementation(async (_roomId: string, _event: string, stateKey: string) => { + if (stateKey === "@alice:example.org") { + return { is_direct: params.senderDirect === true }; } - return ev; + if (stateKey === "@bot:example.org") { + return { is_direct: params.selfDirect === true }; + } + return {}; }), - }; + } as unknown as MatrixClient; } -// --------------------------------------------------------------------------- -// 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); - }); - }); - - 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("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", - selfUserId: "@bot:example.org", - }); - - expect(result).toBe(true); - }); + }), + ).resolves.toBe(true); + expect(client.getJoinedRoomMembers).toHaveBeenCalledWith("!room:example.org"); }); - describe("conservative fallback (memberCount + room name)", () => { - it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => { - const client = createMockClient({ - dmRooms: {}, - membersByRoom: { - "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], - }, - stateEvents: { - // is_direct not set on either member (e.g. Continuwuity bug) - "!broken-dm:example.org|m.room.member|@alice:example.org": {}, - "!broken-dm:example.org|m.room.member|@bot:example.org": {}, - // No m.room.name -> getRoomStateEvent will throw (event not found) - }, - }); - const tracker = createDirectRoomTracker(client as never); - - const result = await tracker.isDirectMessage({ - roomId: "!broken-dm:example.org", - senderId: "@alice:example.org", - }); - - expect(result).toBe(true); - }); - - it("returns true for 2-member room with empty room name", async () => { - const client = createMockClient({ - dmRooms: {}, - membersByRoom: { - "!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"], - }, - stateEvents: { - "!broken-dm:example.org|m.room.member|@alice:example.org": {}, - "!broken-dm:example.org|m.room.member|@bot:example.org": {}, - "!broken-dm:example.org|m.room.name|": { name: "" }, - }, - }); - const tracker = createDirectRoomTracker(client as never); - - const result = await tracker.isDirectMessage({ - roomId: "!broken-dm:example.org", - 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("uses is_direct member flags when present", async () => { + const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true })); + 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(); - }); - }); - - 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); - - const result = await tracker.isDirectMessage({ - roomId: "!failing:example.org", - senderId: "@alice:example.org", - }); - - // Cannot determine member count -> conservative: classify as group - expect(result).toBe(false); - }); - - 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); - - const result = await tracker.isDirectMessage({ - roomId: "!no-name:example.org", - senderId: "@alice:example.org", - }); - - expect(result).toBe(true); - }); - - 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); - - const result = await tracker.isDirectMessage({ - roomId: "!error-room:example.org", - senderId: "@alice:example.org", - }); - - // Network error -> don't assume DM, classify as group - expect(result).toBe(false); - }); - - 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); - }); + }), + ).resolves.toBe(true); }); }); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index 43b935b35fa..de767e1db08 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "../sdk.js"; type DirectMessageCheck = { roomId: string; @@ -8,24 +8,12 @@ type DirectMessageCheck = { type DirectRoomTrackerOptions = { log?: (message: string) => void; - includeMemberCountInLogs?: boolean; }; const DM_CACHE_TTL_MS = 30_000; -/** - * 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; -} - 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(); @@ -90,12 +78,17 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr 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 memberCount = await resolveMemberCount(roomId); + if (memberCount === 2) { + log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`); + return true; + } + const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); const directViaState = (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? "")); @@ -104,47 +97,6 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr 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"}`); return false; }, diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 9179cf69ee3..bda9748a32c 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -1,172 +1,234 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; -import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { MatrixAuth } from "../client.js"; +import type { MatrixClient } from "../sdk.js"; import { registerMatrixMonitorEvents } from "./events.js"; import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; -const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +type RoomEventListener = (roomId: string, event: MatrixRawEvent) => 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", () => { - beforeEach(() => { - sendReadReceiptMatrixMock.mockClear(); +function createHarness(params?: { + verifications?: Array<{ + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + phaseName: string; + updatedAt?: string; + completed?: boolean; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + }>; +}) { + const listeners = new Map void>(); + const onRoomMessage = vi.fn(async () => {}); + const listVerifications = vi.fn(async () => params?.verifications ?? []); + const sendMessage = vi.fn(async () => "$notice"); + const client = { + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + return client; + }), + sendMessage, + crypto: { + listVerifications, + }, + } as unknown as MatrixClient; + + registerMatrixMonitorEvents({ + client, + auth: { encryption: true } as MatrixAuth, + logVerboseMessage: vi.fn(), + warnedEncryptedRooms: new Set(), + warnedCryptoMissingRooms: new Set(), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + formatNativeDependencyHint: vi.fn(() => "install hint"), + 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"); } - it("sends read receipt immediately for non-self messages", async () => { - const { client, onRoomMessage, roomMessageHandler } = createHarness(); - const event = { - event_id: "$e1", + return { + onRoomMessage, + sendMessage, + roomEventListener, + listVerifications, + roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined, + }; +} + +describe("registerMatrixMonitorEvents verification routing", () => { + it("forwards reaction room events into the shared room handler", async () => { + const { onRoomMessage, sendMessage, roomEventListener } = createHarness(); + + roomEventListener("!room:example.org", { + event_id: "$reaction1", sender: "@alice:example.org", - } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); - - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); - await vi.waitFor(() => { - expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client); + type: EventType.Reaction, + origin_server_ts: Date.now(), + content: { + "m.relates_to": { + rel_type: "m.annotation", + event_id: "$msg1", + key: "👍", + }, + }, }); - }); - - it("does not send read receipts for self messages", async () => { - const { onRoomMessage, roomMessageHandler } = createHarness(); - const event = { - event_id: "$e2", - sender: "@bot:example.org", - } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); - }); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); - }); - - it("skips receipt when message lacks sender or event id", async () => { - const { onRoomMessage, roomMessageHandler } = createHarness(); - const event = { - sender: "@alice:example.org", - } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); - await vi.waitFor(() => { - expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event); - }); - expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled(); - }); - - it("caches self user id across messages", async () => { - const { getUserId, roomMessageHandler } = createHarness(); - const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent; - const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", first); - roomMessageHandler("!room:example.org", second); await vi.waitFor(() => { - expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2); - }); - 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 = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent; - - 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")), + 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", + }, }); - const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent; - - roomMessageHandler("!room:example.org", event); 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({ + verifications: [ + { + id: "verification-1", + transactionId: "$different-flow-id", + updatedAt: new Date("2026-02-25T21:42:54.000Z").toISOString(), + otherUserId: "@alice:example.org", + phaseName: "started", + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["🎁", "Gift"], + ["🌍", "Globe"], + ["🐴", "Horse"], + ], + }, + }, + ], + }); + + roomEventListener("!room:example.org", { + event_id: "$start2", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req2" }, + }, + }); + + await vi.waitFor(() => { + const bodies = (sendMessage.mock.calls as unknown[][]).map((call) => + String((call[1] as { body?: string } | undefined)?.body ?? ""), + ); + expect(bodies.some((body) => body.includes("SAS emoji:"))).toBe(true); + expect(bodies.some((body) => body.includes("SAS decimal: 6158 1986 3513"))).toBe(true); + }); + }); + + it("does not emit duplicate SAS notices for the same verification payload", async () => { + const { sendMessage, roomEventListener, listVerifications } = createHarness({ + verifications: [ + { + id: "verification-3", + transactionId: "$req3", + otherUserId: "@alice:example.org", + phaseName: "started", + sas: { + decimal: [1111, 2222, 3333], + emoji: [ + ["🚀", "Rocket"], + ["🦋", "Butterfly"], + ["📕", "Book"], + ], + }, + }, + ], + }); + + roomEventListener("!room:example.org", { + event_id: "$start3", + sender: "@alice:example.org", + type: "m.key.verification.start", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req3" }, + }, + }); + await vi.waitFor(() => { + expect(sendMessage.mock.calls.length).toBeGreaterThan(0); + }); + + roomEventListener("!room:example.org", { + event_id: "$key3", + sender: "@alice:example.org", + type: "m.key.verification.key", + origin_server_ts: Date.now(), + content: { + "m.relates_to": { event_id: "$req3" }, + }, + }); + await vi.waitFor(() => { + expect(listVerifications).toHaveBeenCalledTimes(2); + }); + + const sasBodies = sendMessage.mock.calls + .map((call) => String(((call as unknown[])[1] as { body?: string } | undefined)?.body ?? "")) + .filter((body) => body.includes("SAS emoji:")); + expect(sasBodies).toHaveLength(1); }); }); diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index edc9e2f5edd..6ea81cdf7a8 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,49 +1,237 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import type { MatrixAuth } from "../client.js"; -import { sendReadReceiptMatrix } from "../send.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 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; +const MAX_TRACKED_VERIFICATION_EVENTS = 1024; + +type MatrixVerificationStage = "request" | "ready" | "start" | "cancel" | "done" | "other"; + +type MatrixVerificationSummaryLike = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + phaseName: string; + updatedAt?: string; + completed?: boolean; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; +}; + +function trimMaybeString(input: unknown): string | null { + if (typeof input !== "string") { + return null; + } + const trimmed = input.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function readVerificationSignal(event: MatrixRawEvent): { + stage: MatrixVerificationStage; + flowId: string | null; +} | null { + const type = trimMaybeString(event?.type) ?? ""; + const content = event?.content ?? {}; + const msgtype = trimMaybeString((content as { msgtype?: unknown }).msgtype) ?? ""; + const relatedEventId = trimMaybeString( + (content as { "m.relates_to"?: { event_id?: unknown } })["m.relates_to"]?.event_id, + ); + const transactionId = trimMaybeString((content as { transaction_id?: unknown }).transaction_id); + if (type === EventType.RoomMessage && isMatrixVerificationRequestMsgType(msgtype)) { + return { + stage: "request", + flowId: trimMaybeString(event.event_id) ?? transactionId ?? relatedEventId, + }; + } + if (!isMatrixVerificationEventType(type)) { + return null; + } + const flowId = transactionId ?? relatedEventId ?? trimMaybeString(event.event_id); + if (type === `${matrixVerificationConstants.eventPrefix}request`) { + return { stage: "request", flowId }; + } + if (type === `${matrixVerificationConstants.eventPrefix}ready`) { + return { stage: "ready", flowId }; + } + if (type === "m.key.verification.start") { + return { stage: "start", flowId }; + } + if (type === "m.key.verification.cancel") { + return { stage: "cancel", flowId }; + } + if (type === "m.key.verification.done") { + return { stage: "done", flowId }; + } + return { stage: "other", flowId }; +} + +function formatVerificationStageNotice(params: { + stage: MatrixVerificationStage; + senderId: string; + event: MatrixRawEvent; +}): string | null { + const { stage, senderId, event } = params; + const content = event.content as { code?: unknown; reason?: unknown }; + switch (stage) { + case "request": + return `Matrix verification request received from ${senderId}. Open "Verify by emoji" in your Matrix client to continue.`; + case "ready": + return `Matrix verification is ready with ${senderId}. Choose "Verify by emoji" to reveal the emoji sequence.`; + case "start": + return `Matrix verification started with ${senderId}.`; + case "done": + return `Matrix verification completed with ${senderId}.`; + case "cancel": { + const code = trimMaybeString(content.code); + const reason = trimMaybeString(content.reason); + if (code && reason) { + return `Matrix verification cancelled by ${senderId} (${code}: ${reason}).`; } - registeredClients.add(client); - return true; - }, - }; -})(); - -function createSelfUserIdResolver(client: Pick) { - let selfUserId: string | undefined; - let selfUserIdLookup: Promise | undefined; - - return async (): Promise => { - if (selfUserId) { - return selfUserId; + if (reason) { + return `Matrix verification cancelled by ${senderId} (${reason}).`; + } + return `Matrix verification cancelled by ${senderId}.`; } - if (!selfUserIdLookup) { - selfUserIdLookup = client - .getUserId() - .then((userId) => { - selfUserId = userId; - return userId; - }) - .catch(() => undefined) - .finally(() => { - if (!selfUserId) { - selfUserIdLookup = undefined; - } - }); - } - return await selfUserIdLookup; + 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; +} + +async function resolveVerificationSummaryForSignal( + client: MatrixClient, + params: { + event: MatrixRawEvent; + senderId: string; + flowId: string | null; + }, +): Promise { + if (!client.crypto) { + return null; + } + const list = await client.crypto.listVerifications(); + if (list.length === 0) { + return null; + } + const candidates = resolveVerificationFlowCandidates({ + event: params.event, + flowId: params.flowId, + }); + const byTransactionId = list.find((entry) => + candidates.some((candidate) => entry.transactionId === candidate), + ); + if (byTransactionId) { + return byTransactionId; + } + + // Fallback for flows where transaction IDs do not match room event IDs consistently. + const byUser = list + .filter((entry) => entry.otherUserId === params.senderId && entry.completed !== true) + .sort((a, b) => resolveSummaryRecency(b) - resolveSummaryRecency(a))[0]; + return byUser ?? null; +} + +function trackBounded(set: Set, 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 registerMatrixMonitorEvents(params: { @@ -56,11 +244,6 @@ 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 { client, auth, @@ -71,26 +254,69 @@ export function registerMatrixMonitorEvents(params: { formatNativeDependencyHint, onRoomMessage, } = params; + const routedVerificationEvents = new Set(); + const routedVerificationSasFingerprints = new Set(); - 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)}`, - ); - }); - })(); + const routeVerificationEvent = (roomId: string, event: MatrixRawEvent): boolean => { + const senderId = trimMaybeString(event?.sender); + if (!senderId) { + return false; + } + const signal = readVerificationSignal(event); + if (!signal) { + return false; } - onRoomMessage(roomId, event); + void (async () => { + const flowId = signal.flowId; + const sourceEventId = trimMaybeString(event?.event_id); + const sourceFingerprint = sourceEventId ?? `${senderId}:${event.type}:${flowId ?? "none"}`; + if (!trackBounded(routedVerificationEvents, sourceFingerprint)) { + return; + } + + const stageNotice = formatVerificationStageNotice({ stage: signal.stage, senderId, event }); + const summary = await resolveVerificationSummaryForSignal(client, { + event, + senderId, + flowId, + }).catch(() => null); + const sasNotice = summary ? formatVerificationSasNotice(summary) : null; + + const notices: string[] = []; + if (stageNotice) { + notices.push(stageNotice); + } + if (summary && sasNotice) { + const sasFingerprint = `${summary.id}:${JSON.stringify(summary.sas)}`; + if (trackBounded(routedVerificationSasFingerprints, sasFingerprint)) { + notices.push(sasNotice); + } + } + if (notices.length === 0) { + return; + } + + for (const body of notices) { + await sendVerificationNotice({ + client, + roomId, + body, + logVerboseMessage, + }); + } + })().catch((err) => { + logVerboseMessage(`matrix: failed routing verification event: ${String(err)}`); + }); + + return true; + }; + + client.on("room.message", (roomId: string, event: MatrixRawEvent) => { + if (routeVerificationEvent(roomId, event)) { + return; + } + void onRoomMessage(roomId, event); }); client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { @@ -164,5 +390,11 @@ export function registerMatrixMonitorEvents(params: { `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 15665563039..d54e57d0af2 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,11 +1,7 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { describe, expect, it, vi } from "vitest"; -import { - createMatrixRoomMessageHandler, - resolveMatrixBaseRouteSession, - shouldOverrideMatrixDmToGroup, -} from "./handler.js"; +import type { MatrixClient } from "../sdk.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; import { EventType, type MatrixRawEvent } from "./types.js"; describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { @@ -22,15 +18,8 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { channel: { pairing: { readAllowFromStore: vi.fn().mockResolvedValue([]), - upsertPairingRequest: vi.fn().mockResolvedValue(undefined), }, 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, @@ -114,7 +103,7 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { altAliases: [], }), getMemberDisplayName: vi.fn().mockResolvedValue("Bu"), - accountId: undefined, + accountId: "default", }); const event = { @@ -150,47 +139,4 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => { }), ); }); - - 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({ - isDirectMessage: true, - roomConfigInfo: { - config: { allow: false }, - allowed: false, - matchSource: "direct", - }, - }), - ).toBe(false); - }); }); diff --git a/extensions/matrix-js/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts similarity index 98% rename from extensions/matrix-js/src/matrix/monitor/handler.test.ts rename to extensions/matrix/src/matrix/monitor/handler.test.ts index 69825a005df..f0198cbafe4 100644 --- a/extensions/matrix-js/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -34,7 +34,7 @@ function createReactionHarness(params?: { const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); const resolveAgentRoute = vi.fn(() => ({ agentId: "ops", - channel: "matrix-js", + channel: "matrix", accountId: "ops", sessionKey: "agent:ops:main", mainSessionKey: "agent:ops:main", @@ -307,12 +307,12 @@ describe("matrix monitor handler pairing account scope", () => { } as MatrixRawEvent); expect(readAllowFromStore).toHaveBeenCalledWith({ - channel: "matrix-js", + channel: "matrix", env: process.env, accountId: "ops", }); expect(upsertPairingRequest).toHaveBeenCalledWith({ - channel: "matrix-js", + channel: "matrix", id: "@user:example.org", accountId: "ops", meta: { name: "sender" }, @@ -322,7 +322,7 @@ describe("matrix monitor handler pairing account scope", () => { it("passes accountId into route resolution for inbound dm messages", async () => { const resolveAgentRoute = vi.fn(() => ({ agentId: "ops", - channel: "matrix-js", + channel: "matrix", accountId: "ops", sessionKey: "agent:ops:main", mainSessionKey: "agent:ops:main", @@ -392,7 +392,7 @@ describe("matrix monitor handler pairing account scope", () => { expect(resolveAgentRoute).toHaveBeenCalledWith( expect.objectContaining({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", }), ); @@ -432,7 +432,7 @@ describe("matrix monitor handler pairing account scope", () => { routing: { resolveAgentRoute: () => ({ agentId: "ops", - channel: "matrix-js", + channel: "matrix", accountId: "ops", sessionKey: "agent:ops:main", mainSessionKey: "agent:ops:main", @@ -525,7 +525,7 @@ describe("matrix monitor handler pairing account scope", () => { it("routes bound Matrix threads to the target session key", async () => { registerSessionBindingAdapter({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", listBySession: () => [], resolveByConversation: (ref) => @@ -535,7 +535,7 @@ describe("matrix monitor handler pairing account scope", () => { targetSessionKey: "agent:bound:session-1", targetKind: "session", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$root", parentConversationId: "!room:example", @@ -581,7 +581,7 @@ describe("matrix monitor handler pairing account scope", () => { routing: { resolveAgentRoute: () => ({ agentId: "ops", - channel: "matrix-js", + channel: "matrix", accountId: "ops", sessionKey: "agent:ops:main", mainSessionKey: "agent:ops:main", @@ -684,7 +684,7 @@ describe("matrix monitor handler pairing account scope", () => { expect(resolveAgentRoute).toHaveBeenCalledWith( expect.objectContaining({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", }), ); @@ -747,7 +747,7 @@ describe("matrix monitor handler pairing account scope", () => { const { handler, enqueueSystemEvent } = createReactionHarness({ cfg: { channels: { - "matrix-js": { + matrix: { reactionNotifications: "own", accounts: { ops: { diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 0adc9fa2886..58d5dd6e704 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,58 +1,65 @@ -import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { - DEFAULT_ACCOUNT_ID, - createScopedPairingAccess, createReplyPrefixOptions, createTypingCallbacks, - dispatchReplyFromConfigWithSettledDispatcher, - evaluateGroupRouteAccessForPolicy, + ensureConfiguredAcpRouteReady, formatAllowlistMatchMeta, + getSessionBindingService, logInboundDrop, logTypingFailure, - resolveInboundSessionEnvelopeContext, + resolveAgentIdFromSessionKey, + resolveConfiguredAcpRoute, resolveControlCommandGate, type PluginRuntime, + type ReplyPayload, type RuntimeEnv, type RuntimeLogger, } from "openclaw/plugin-sdk/matrix"; import type { CoreConfig, MatrixRoomConfig, ReplyToMode } from "../../types.js"; -import { fetchEventSummary } from "../actions/summary.js"; import { formatPollAsText, 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 { + reactMatrixMessage, + sendMessageMatrix, + sendReadReceiptMatrix, + sendTypingMatrix, +} from "../send.js"; +import { resolveMatrixAckReactionConfig } from "./ack-config.js"; import { normalizeMatrixAllowList, resolveMatrixAllowListMatch, resolveMatrixAllowListMatches, } from "./allowlist.js"; -import { - resolveMatrixBodyForAgent, - resolveMatrixInboundSenderLabel, - resolveMatrixSenderUsername, -} from "./inbound-body.js"; import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; import { downloadMatrixMedia } from "./media.js"; import { resolveMentions } from "./mentions.js"; +import { handleInboundMatrixReaction } from "./reaction-events.js"; import { deliverMatrixReplies } from "./replies.js"; import { resolveMatrixRoomConfig } from "./rooms.js"; +import { createMatrixThreadContextResolver } from "./thread-context.js"; import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; import { EventType, RelationType } from "./types.js"; +import { isMatrixVerificationRoomMessage } from "./verification-utils.js"; + +const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000; +const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000; +const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512; export type MatrixMonitorHandlerParams = { client: MatrixClient; core: PluginRuntime; cfg: CoreConfig; + accountId: string; runtime: RuntimeEnv; logger: RuntimeLogger; logVerboseMessage: (message: string) => void; allowFrom: string[]; - roomsConfig: Record | undefined; + roomsConfig?: Record; mentionRegexes: ReturnType; groupPolicy: "open" | "allowlist" | "disabled"; replyToMode: ReplyToMode; @@ -74,64 +81,14 @@ export type MatrixMonitorHandlerParams = { roomId: string, ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; getMemberDisplayName: (roomId: string, userId: string) => Promise; - accountId?: string | null; }; -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", - }; -} - -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" - ); -} - export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { const { client, core, cfg, + accountId, runtime, logger, logVerboseMessage, @@ -150,33 +107,81 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam directTracker, getRoomInfo, getMemberDisplayName, - accountId, } = params; - const resolvedAccountId = accountId?.trim() || DEFAULT_ACCOUNT_ID; - const pairing = createScopedPairingAccess({ - 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 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; @@ -206,9 +211,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam const roomName = roomInfo.name; const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean); - let content = event.content as unknown as RoomMessageEventContent; + let content = event.content as RoomMessageEventContent; if (isPollEvent) { - const pollStartContent = event.content as unknown as PollStartContent; + const pollStartContent = event.content as PollStartContent; const pollSummary = parsePollStartContent(pollStartContent); if (pollSummary) { pollSummary.eventId = event.event_id ?? ""; @@ -226,6 +231,17 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } } + if ( + eventType === EventType.RoomMessage && + isMatrixVerificationRoomMessage({ + msgtype: (content as { msgtype?: unknown }).msgtype, + body: content.body, + }) + ) { + logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`); + return; + } + const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ eventType, content: content as LocationMessageEventContent, @@ -238,99 +254,108 @@ 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 roomConfigInfo = isRoom + ? resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliases, + name: roomName, + }) + : undefined; + const roomConfig = roomConfigInfo?.config; const roomMatchMeta = roomConfigInfo ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ roomConfigInfo.matchSource ?? "none" }` : "matchKey=none matchSource=none"; - if (isRoom) { - 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, - senderId, - senderUsername, - }); - const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; - const { access, effectiveAllowFrom, effectiveGroupAllowFrom, groupAllowConfigured } = - await resolveMatrixAccessState({ - isDirectMessage, - resolvedAccountId, - dmPolicy, - groupPolicy, - allowFrom, - groupAllowFrom, - senderId, - readStoreForDmPolicy: pairing.readStoreForDmPolicy, - }); + const storeAllowFrom = await readStoreAllowFrom(); + const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); + const groupAllowFrom = cfg.channels?.["matrix"]?.groupAllowFrom ?? []; + const effectiveGroupAllowFrom = normalizeMatrixAllowList(groupAllowFrom); + const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; if (isDirectMessage) { - const allowedDirectMessage = await enforceMatrixDirectMessageAccess({ - dmEnabled, - dmPolicy, - accessDecision: access.decision, - senderId, - senderName, - effectiveAllowFrom, - upsertPairingRequest: pairing.upsertPairingRequest, - sendPairingReply: async (text) => { - await sendMessageMatrix(`room:${roomId}`, text, { client }); - }, - logVerboseMessage, - }); - if (!allowedDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { return; } + if (dmPolicy !== "open") { + const allowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveAllowFrom, + userId: senderId, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (!allowMatch.allowed) { + if (!isReactionEvent && dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + 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 }, + ); + } 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 ?? []; @@ -348,7 +373,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } } - if (isRoom && roomUsers.length === 0 && groupAllowConfigured && access.decision !== "allow") { + if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { const groupAllowMatch = resolveMatrixAllowListMatch({ allowList: effectiveGroupAllowFrom, userId: senderId, @@ -366,6 +391,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); } + if (isReactionEvent) { + await handleInboundMatrixReaction({ + client, + core, + cfg, + accountId, + roomId, + event, + senderId, + senderLabel: senderName, + selfUserId, + isDirectMessage, + logVerboseMessage, + }); + return; + } + const rawBody = locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); let media: { @@ -489,8 +531,11 @@ 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({ cfg, @@ -500,105 +545,90 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam 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, + const bindingConversationId = + threadRootId && threadRootId !== messageId ? threadRootId : roomId; + const bindingParentConversationId = bindingConversationId === roomId ? undefined : roomId; + const sessionBindingService = getSessionBindingService(); + const runtimeBinding = sessionBindingService.resolveByConversation({ + channel: "matrix", accountId, + conversationId: bindingConversationId, + parentConversationId: bindingParentConversationId, }); - - 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, + const configuredRoute = + runtimeBinding == null + ? resolveConfiguredAcpRoute({ + cfg, + route: baseRoute, + channel: "matrix", + accountId, + conversationId: bindingConversationId, + parentConversationId: bindingParentConversationId, + }) + : null; + const configuredBinding = configuredRoute?.configuredBinding ?? null; + if (!runtimeBinding && configuredBinding) { + const ensured = await ensureConfiguredAcpRouteReady({ + 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 boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); + const route = + runtimeBinding && boundSessionKey + ? { + ...baseRoute, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey) || baseRoute.agentId, + matchedBy: "binding.channel" as const, + } + : (configuredRoute?.route ?? baseRoute); + if (runtimeBinding) { + sessionBindingService.touch(runtimeBinding.bindingId, eventTs); + } const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = 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, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, @@ -608,6 +638,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, @@ -617,9 +648,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({ @@ -646,8 +674,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 && @@ -674,6 +705,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam return; } + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + let didSendReply = false; const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, @@ -712,8 +751,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam core.channel.reply.createReplyDispatcherWithTyping({ ...prefixOptions, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), - typingCallbacks, - deliver: async (payload) => { + deliver: async (payload: ReplyPayload) => { await deliverMatrixReplies({ replies: [payload], roomId, @@ -727,24 +765,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam }); 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; } diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts deleted file mode 100644 index 89ae5188e9c..00000000000 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DEFAULT_STARTUP_GRACE_MS, isConfiguredMatrixRoomEntry } from "./index.js"; - -describe("monitorMatrixProvider helpers", () => { - it("treats !-prefixed room IDs as configured room entries", () => { - expect(isConfiguredMatrixRoomEntry("!abc123")).toBe(true); - expect(isConfiguredMatrixRoomEntry("!RoomMixedCase")).toBe(true); - }); - - it("requires a homeserver suffix for # aliases", () => { - expect(isConfiguredMatrixRoomEntry("#alias:example.org")).toBe(true); - expect(isConfiguredMatrixRoomEntry("#alias")).toBe(false); - }); - - it("uses a non-zero startup grace window", () => { - expect(DEFAULT_STARTUP_GRACE_MS).toBe(5000); - }); -}); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 1634a75502b..161eb28150a 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,7 +1,9 @@ +import { format } from "node:util"; import { GROUP_POLICY_BLOCKED_LABEL, mergeAllowlist, - resolveRuntimeEnv, + resolveThreadBindingIdleTimeoutMsForChannel, + resolveThreadBindingMaxAgeMsForChannel, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, summarizeMapping, @@ -10,7 +12,7 @@ import { } from "openclaw/plugin-sdk/matrix"; import { resolveMatrixTargets } from "../../resolve-targets.js"; 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 { @@ -19,12 +21,17 @@ import { resolveSharedMatrixClient, stopSharedClientForAccount, } from "../client.js"; +import { updateMatrixAccountConfig } from "../config-update.js"; +import { syncMatrixOwnProfile } from "../profile.js"; +import { createMatrixThreadBindingManager } from "../thread-bindings.js"; import { normalizeMatrixUserId } from "./allowlist.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; import { createDirectRoomTracker } from "./direct.js"; import { registerMatrixMonitorEvents } from "./events.js"; import { createMatrixRoomMessageHandler } from "./handler.js"; +import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js"; import { createMatrixRoomInfoResolver } from "./room-info.js"; +import { ensureMatrixStartupVerification } from "./startup-verification.js"; export type MonitorMatrixOpts = { runtime?: RuntimeEnv; @@ -36,199 +43,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 +50,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 +74,163 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi logger.debug?.(message); }; + const normalizeUserEntry = (raw: string) => + raw + .replace(/^matrix:/i, "") + .replace(/^user:/i, "") + .trim(); + const normalizeRoomEntry = (raw: string) => + raw + .replace(/^matrix:/i, "") + .replace(/^(room|channel):/i, "") + .trim(); + const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + const resolveUserAllowlist = async ( + label: string, + list?: Array, + ): Promise => { + let allowList = list ?? []; + if (allowList.length === 0) { + return allowList.map(String); + } + const entries = allowList + .map((entry) => normalizeUserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (entries.length === 0) { + return allowList.map(String); + } + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + const pending: string[] = []; + for (const entry of entries) { + if (isMatrixUserId(entry)) { + additions.push(normalizeMatrixUserId(entry)); + continue; + } + pending.push(entry); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + runtime, + }); + for (const entry of resolved) { + if (entry.resolved && entry.id) { + const normalizedId = normalizeMatrixUserId(entry.id); + additions.push(normalizedId); + mapping.push(`${entry.input}→${normalizedId}`); + } else { + unresolved.push(entry.input); + } + } + } + allowList = mergeAllowlist({ existing: allowList, additions }); + summarizeMapping(label, mapping, unresolved, runtime); + if (unresolved.length > 0) { + runtime.log?.( + `${label} entries must be full Matrix IDs (example: @user:server). Unresolved entries are ignored.`, + ); + } + return allowList.map(String); + }; + // Resolve account-specific config for multi-account support const account = resolveMatrixAccount({ cfg, accountId: opts.accountId }); const accountConfig = account.config; + const allowlistOnly = accountConfig.allowlistOnly === true; - const { allowFrom, groupAllowFrom, roomsConfig } = await resolveMatrixMonitorConfig({ - cfg, - runtime, - accountConfig, - }); + let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String); + let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String); + let roomsConfig = accountConfig.groups ?? accountConfig.rooms; + + allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom); + groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom); + + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const mapping: string[] = []; + const unresolved: string[] = []; + const nextRooms: Record = {}; + if (roomsConfig["*"]) { + nextRooms["*"] = roomsConfig["*"]; + } + const pending: Array<{ input: string; query: string; config: (typeof roomsConfig)[string] }> = + []; + for (const [entry, roomConfig] of Object.entries(roomsConfig)) { + if (entry === "*") { + continue; + } + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = normalizeRoomEntry(trimmed); + if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) { + if (!nextRooms[cleaned]) { + nextRooms[cleaned] = roomConfig; + } + if (cleaned !== entry) { + mapping.push(`${entry}→${cleaned}`); + } + continue; + } + pending.push({ input: entry, query: trimmed, config: roomConfig }); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending.map((entry) => entry.query), + kind: "group", + runtime, + }); + resolved.forEach((entry, index) => { + const source = pending[index]; + if (!source) { + return; + } + if (entry.resolved && entry.id) { + if (!nextRooms[entry.id]) { + nextRooms[entry.id] = source.config; + } + mapping.push(`${source.input}→${entry.id}`); + } else { + unresolved.push(source.input); + } + }); + } + roomsConfig = nextRooms; + summarizeMapping("matrix rooms", mapping, unresolved, runtime); + if (unresolved.length > 0) { + runtime.log?.( + "matrix rooms must be room IDs or aliases (example: !room:server or #alias:server). Unresolved entries are ignored.", + ); + } + } + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const nextRooms = { ...roomsConfig }; + for (const [roomKey, roomConfig] of Object.entries(roomsConfig)) { + const users = roomConfig?.users ?? []; + if (users.length === 0) { + continue; + } + const resolvedUsers = await resolveUserAllowlist(`matrix room users (${roomKey})`, users); + if (resolvedUsers !== users) { + nextRooms[roomKey] = { ...roomConfig, users: resolvedUsers }; + } + } + roomsConfig = nextRooms; + } cfg = { ...cfg, channels: { ...cfg.channels, matrix: { - ...cfg.channels?.matrix, + ...cfg.channels?.["matrix"], dm: { - ...cfg.channels?.matrix?.dm, + ...cfg.channels?.["matrix"]?.dm, allowFrom, }, groupAllowFrom, @@ -291,7 +252,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi cfg, auth: authWithLimit, startClient: false, - accountId: opts.accountId, + accountId: opts.accountId ?? undefined, }); setActiveMatrixClient(client, opts.accountId); @@ -299,7 +260,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({ - providerConfigPresent: cfg.channels?.matrix !== undefined, + providerConfigPresent: cfg.channels?.["matrix"] !== undefined, groupPolicy: accountConfig.groupPolicy, defaultGroupPolicy, }); @@ -313,6 +274,16 @@ 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"; @@ -321,11 +292,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi 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(), - }); + const startupGraceMs = 0; + const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); registerMatrixAutoJoin({ client, cfg, runtime }); const warnedEncryptedRooms = new Set(); const warnedCryptoMissingRooms = new Set(); @@ -335,6 +303,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi client, core, cfg, + accountId: account.accountId, runtime, logger, logVerboseMessage, @@ -353,7 +322,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi directTracker, getRoomInfo, getMemberDisplayName, - accountId: opts.accountId, }); registerMatrixMonitorEvents({ @@ -374,22 +342,128 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi accountId: opts.accountId, }); logVerboseMessage("matrix: client started"); + const threadBindingManager = await createMatrixThreadBindingManager({ + accountId: account.accountId, + auth, + client, + env: process.env, + idleTimeoutMs: threadBindingIdleTimeoutMs, + maxAgeMs: threadBindingMaxAgeMs, + logVerboseMessage, + }); + logVerboseMessage( + `matrix: thread bindings ready account=${threadBindingManager.accountId} idleMs=${threadBindingIdleTimeoutMs} maxAgeMs=${threadBindingMaxAgeMs}`, + ); - // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient + // Shared client is already started via resolveSharedMatrixClient. logger.info(`matrix: logged in as ${auth.userId}`); - // If E2EE is enabled, trigger device verification + try { + const profileSync = await syncMatrixOwnProfile({ + client, + userId: auth.userId, + displayName: accountConfig.name, + avatarUrl: accountConfig.avatarUrl, + loadAvatarFromUrl: async (url, maxBytes) => await core.media.loadWebMedia(url, maxBytes), + }); + if (profileSync.displayNameUpdated) { + logger.info(`matrix: profile display name updated for ${auth.userId}`); + } + if (profileSync.avatarUpdated) { + logger.info(`matrix: profile avatar updated for ${auth.userId}`); + } + if ( + profileSync.convertedAvatarFromHttp && + profileSync.resolvedAvatarUrl && + accountConfig.avatarUrl !== profileSync.resolvedAvatarUrl + ) { + const latestCfg = core.config.loadConfig() as CoreConfig; + const updatedCfg = updateMatrixAccountConfig(latestCfg, account.accountId, { + avatarUrl: profileSync.resolvedAvatarUrl, + }); + await core.config.writeConfigFile(updatedCfg as never); + logVerboseMessage( + `matrix: persisted converted avatar URL for account ${account.accountId} (${profileSync.resolvedAvatarUrl})`, + ); + } + } catch (err) { + logger.warn("matrix: failed to sync profile from config", { error: String(err) }); + } + + // If E2EE is enabled, report device verification status and request self-verification + // when configured and the device is still unverified. if (auth.encryption && client.crypto) { try { - // 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"); + const startupVerification = await ensureMatrixStartupVerification({ + client, + auth, + accountConfig, + accountId: account.accountId, + env: process.env, + }); + if (startupVerification.kind === "verified") { + logger.info("matrix: device is verified and ready for encrypted rooms"); + } else if ( + startupVerification.kind === "disabled" || + startupVerification.kind === "cooldown" || + startupVerification.kind === "pending" || + startupVerification.kind === "request-failed" + ) { + logger.info( + "matrix: device not verified — run 'openclaw matrix verify device ' to enable E2EE", + ); + if (startupVerification.kind === "pending") { + logger.info( + "matrix: startup verification request is already pending; finish it in another Matrix client", + ); + } else if (startupVerification.kind === "cooldown") { + logVerboseMessage( + `matrix: skipped startup verification request due to cooldown (retryAfterMs=${startupVerification.retryAfterMs ?? 0})`, + ); + } else if (startupVerification.kind === "request-failed") { + logger.debug?.("Matrix startup verification request failed (non-fatal)", { + error: startupVerification.error ?? "unknown", + }); + } + } else if (startupVerification.kind === "requested") { + logger.info( + "matrix: device not verified — requested verification in another Matrix client", + ); } } catch (err) { - logger.debug?.("Device verification request failed (may already be verified)", { + logger.debug?.("Failed to resolve matrix verification status (non-fatal)", { + error: String(err), + }); + } + + try { + const legacyCryptoRestore = await maybeRestoreLegacyMatrixBackup({ + client, + auth, + accountId: account.accountId, + env: process.env, + }); + if (legacyCryptoRestore.kind === "restored") { + logger.info( + `matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + 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") { + logger.warn( + `matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`, + ); + if (legacyCryptoRestore.localOnlyKeys > 0) { + logger.warn( + `matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`, + ); + } + } + } catch (err) { + logger.warn("matrix: failed restoring legacy encrypted-state backup", { error: String(err), }); } @@ -398,6 +472,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi await new Promise((resolve) => { const onAbort = () => { try { + threadBindingManager.stop(); logVerboseMessage("matrix: stopping client"); stopSharedClientForAccount(auth, opts.accountId); } finally { 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..6b74361dff6 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts @@ -0,0 +1,146 @@ +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 = { + 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 = { + 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"); + }); + }); +}); 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..f991cd4377c --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.ts @@ -0,0 +1,103 @@ +import os from "node:os"; +import path from "node:path"; +import { + readJsonFileWithFallback, + resolveMatrixAccountStorageRoot, + writeJsonFileAtomically, +} from "openclaw/plugin-sdk/matrix"; +import { getMatrixRuntime } from "../../runtime.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 + ); +} + +export async function maybeRestoreLegacyMatrixBackup(params: { + client: Pick; + auth: Pick; + accountId?: string | null; + env?: NodeJS.ProcessEnv; + stateDir?: string; +}): Promise { + const env = params.env ?? process.env; + const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + accountId: params.accountId, + }); + const statePath = path.join(rootDir, "legacy-crypto-migration.json"); + const { value } = await readJsonFileWithFallback( + statePath, + null, + ); + 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 ff80ea82b5a..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 "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 a3803108af2..7c690b3c4fa 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -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, @@ -67,7 +64,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({ diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index baf366186c4..bfd31b51138 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 = { @@ -20,8 +20,8 @@ async function fetchMatrixMediaBuffer(params: { client: MatrixClient; mxcUrl: string; maxBytes: number; -}): Promise<{ buffer: Buffer; headerType?: string } | null> { - // @vector-im/matrix-bot-sdk provides mxcToHttp helper +}): Promise<{ buffer: Buffer } | null> { + // The client wrapper exposes mxcToHttp for Matrix media URIs. const url = params.client.mxcToHttp(params.mxcUrl); if (!url) { return null; @@ -29,14 +29,13 @@ async function fetchMatrixMediaBuffer(params: { // Use the client's download method which handles auth try { - const result = await params.client.downloadContent(params.mxcUrl); - const raw = result.data ?? result; + const raw = await params.client.downloadContent(params.mxcUrl); const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); if (buffer.byteLength > params.maxBytes) { throw new Error("Matrix media exceeds configured size limit"); } - return { buffer, headerType: result.contentType }; + return { buffer }; } catch (err) { throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err }); } @@ -44,7 +43,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; @@ -103,7 +102,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.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 232e495c88d..aa67386221a 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,15 +1,5 @@ import { getMatrixRuntime } from "../../runtime.js"; - -// Type for room message content with mentions -type MessageContentWithMentions = { - msgtype: string; - body: string; - formatted_body?: string; - "m.mentions"?: { - user_ids?: string[]; - room?: boolean; - }; -}; +import type { RoomMessageEventContent } from "./types.js"; /** * Check if the formatted_body contains a matrix.to mention link for the given user ID. @@ -35,7 +25,7 @@ function checkFormattedBodyMention(formattedBody: string | undefined, userId: st } export function resolveMentions(params: { - content: MessageContentWithMentions; + content: RoomMessageEventContent; userId?: string | null; text?: string; mentionRegexes: RegExp[]; diff --git a/extensions/matrix-js/src/matrix/monitor/reaction-events.ts b/extensions/matrix/src/matrix/monitor/reaction-events.ts similarity index 85% rename from extensions/matrix-js/src/matrix/monitor/reaction-events.ts rename to extensions/matrix/src/matrix/monitor/reaction-events.ts index 76904ff8edc..5631ec3a781 100644 --- a/extensions/matrix-js/src/matrix/monitor/reaction-events.ts +++ b/extensions/matrix/src/matrix/monitor/reaction-events.ts @@ -1,5 +1,6 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +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 type { MatrixRawEvent } from "./types.js"; @@ -10,9 +11,12 @@ export function resolveMatrixReactionNotificationMode(params: { cfg: CoreConfig; accountId: string; }): MatrixReactionNotificationMode { - const matrixConfig = params.cfg.channels?.["matrix-js"]; - const accountConfig = matrixConfig?.accounts?.[params.accountId]; - return accountConfig?.reactionNotifications ?? matrixConfig?.reactionNotifications ?? "own"; + 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: { @@ -58,7 +62,7 @@ export async function handleInboundMatrixReaction(params: { const route = params.core.channel.routing.resolveAgentRoute({ cfg: params.cfg, - channel: "matrix-js", + channel: "matrix", accountId: params.accountId, peer: { kind: params.isDirectMessage ? "direct" : "channel", diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts index 838f955abdf..113871d8f26 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 { MatrixClient } from "../sdk.js"; const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" })); @@ -108,58 +108,6 @@ describe("deliverMatrixReplies", () => { ); }); - 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("|")); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 5f501139dfa..187dd4715e4 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,6 +1,6 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; +import type { MatrixClient } from "../sdk.js"; import { sendMessageMatrix } from "../send.js"; export async function deliverMatrixReplies(params: { @@ -41,11 +41,6 @@ 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 ?? ""; @@ -103,22 +98,3 @@ export async function deliverMatrixReplies(params: { } } } - -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.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 764147d3539..095f1dc307a 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; @@ -19,7 +19,9 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { 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 } @@ -27,8 +29,13 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { 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 } @@ -42,7 +49,10 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { const memberState = await client .getRoomStateEvent(roomId, "m.room.member", userId) .catch(() => null); - return memberState?.displayname ?? userId; + if (memberState && typeof memberState.displayname === "string") { + return memberState.displayname; + } + return userId; } catch { return userId; } diff --git a/extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts similarity index 97% rename from extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts rename to extensions/matrix/src/matrix/monitor/startup-verification.test.ts index a77792a5948..b45674a5ed5 100644 --- a/extensions/matrix-js/src/matrix/monitor/startup-verification.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ensureMatrixStartupVerification } from "./startup-verification.js"; function createTempStateDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-js-startup-verify-")); + return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-startup-verify-")); } function createStateFilePath(rootDir: string): string { @@ -226,6 +226,9 @@ describe("ensureMatrixStartupVerification", () => { }); 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({ diff --git a/extensions/matrix-js/src/matrix/monitor/startup-verification.ts b/extensions/matrix/src/matrix/monitor/startup-verification.ts similarity index 99% rename from extensions/matrix-js/src/matrix/monitor/startup-verification.ts rename to extensions/matrix/src/matrix/monitor/startup-verification.ts index 913649670e3..5cb258c8236 100644 --- a/extensions/matrix-js/src/matrix/monitor/startup-verification.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/matrix-js"; +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"; diff --git a/extensions/matrix-js/src/matrix/monitor/thread-context.test.ts b/extensions/matrix/src/matrix/monitor/thread-context.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/monitor/thread-context.test.ts rename to extensions/matrix/src/matrix/monitor/thread-context.test.ts diff --git a/extensions/matrix-js/src/matrix/monitor/thread-context.ts b/extensions/matrix/src/matrix/monitor/thread-context.ts similarity index 100% rename from extensions/matrix-js/src/matrix/monitor/thread-context.ts rename to extensions/matrix/src/matrix/monitor/thread-context.ts 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-js/src/matrix/monitor/verification-utils.test.ts b/extensions/matrix/src/matrix/monitor/verification-utils.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts rename to extensions/matrix/src/matrix/monitor/verification-utils.test.ts diff --git a/extensions/matrix-js/src/matrix/monitor/verification-utils.ts b/extensions/matrix/src/matrix/monitor/verification-utils.ts similarity index 100% rename from extensions/matrix-js/src/matrix/monitor/verification-utils.ts rename to extensions/matrix/src/matrix/monitor/verification-utils.ts diff --git a/extensions/matrix/src/matrix/poll-types.test.ts b/extensions/matrix/src/matrix/poll-types.test.ts index 7f1797d99c6..3c78ab1b07c 100644 --- a/extensions/matrix/src/matrix/poll-types.test.ts +++ b/extensions/matrix/src/matrix/poll-types.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parsePollStartContent } from "./poll-types.js"; +import { + buildPollResponseContent, + buildPollStartContent, + parsePollStart, + parsePollStartContent, +} from "./poll-types.js"; describe("parsePollStartContent", () => { it("parses legacy m.poll payloads", () => { @@ -18,4 +23,73 @@ 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", + }, + }); + }); }); diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index 068b5fafd99..19b5cc12944 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 "openclaw/plugin-sdk/matrix"; +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,6 +77,26 @@ export type PollSummary = { maxSelections: number; }; +export type ParsedPollStart = { + question: string; + answers: PollParsedAnswer[]; + kind: PollKind; + maxSelections: number; +}; + +export type PollResponseSubtype = { + answers: string[]; +}; + +export type PollResponseContent = { + [M_POLL_RESPONSE]?: PollResponseSubtype; + [ORG_POLL_RESPONSE]?: PollResponseSubtype; + "m.relates_to": { + rel_type: "m.reference"; + event_id: string; + }; +}; + export function isPollStartType(eventType: string): boolean { return (POLL_START_TYPES as readonly string[]).includes(eventType); } @@ -83,7 +108,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 +117,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, }; } @@ -138,30 +189,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-js/src/matrix/probe.test.ts b/extensions/matrix/src/matrix/probe.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/probe.test.ts rename to extensions/matrix/src/matrix/probe.test.ts diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 2919d9d9c2f..0f888e355f4 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -42,13 +42,14 @@ 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, }); - // @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-js/src/matrix/profile.test.ts b/extensions/matrix/src/matrix/profile.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/profile.test.ts rename to extensions/matrix/src/matrix/profile.test.ts diff --git a/extensions/matrix-js/src/matrix/profile.ts b/extensions/matrix/src/matrix/profile.ts similarity index 100% rename from extensions/matrix-js/src/matrix/profile.ts rename to extensions/matrix/src/matrix/profile.ts diff --git a/extensions/matrix-js/src/matrix/reaction-common.test.ts b/extensions/matrix/src/matrix/reaction-common.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/reaction-common.test.ts rename to extensions/matrix/src/matrix/reaction-common.test.ts diff --git a/extensions/matrix-js/src/matrix/reaction-common.ts b/extensions/matrix/src/matrix/reaction-common.ts similarity index 100% rename from extensions/matrix-js/src/matrix/reaction-common.ts rename to extensions/matrix/src/matrix/reaction-common.ts 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-js/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk.test.ts rename to extensions/matrix/src/matrix/sdk.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts similarity index 99% rename from extensions/matrix-js/src/matrix/sdk.ts rename to extensions/matrix/src/matrix/sdk.ts index 36096cc0fa6..3888c28a56f 100644 --- a/extensions/matrix-js/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -774,7 +774,7 @@ export class MatrixClient { if (!activeVersion) { if (typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage !== "function") { return await fail( - "Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix-js verify device ' first.", + "Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device ' first.", ); } await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts rename to extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts similarity index 99% rename from extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts rename to extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index 9af37da24ae..c403f4caf2a 100644 --- a/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -75,7 +75,7 @@ export class MatrixCryptoBootstrapper { } catch { if (!params.password?.trim()) { throw new Error( - "Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback", + "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", ); } return await makeRequest({ diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts rename to extensions/matrix/src/matrix/sdk/crypto-facade.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/crypto-facade.ts rename to extensions/matrix/src/matrix/sdk/crypto-facade.ts diff --git a/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts rename to extensions/matrix/src/matrix/sdk/decrypt-bridge.ts diff --git a/extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts rename to extensions/matrix/src/matrix/sdk/event-helpers.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/event-helpers.ts b/extensions/matrix/src/matrix/sdk/event-helpers.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/event-helpers.ts rename to extensions/matrix/src/matrix/sdk/event-helpers.ts diff --git a/extensions/matrix-js/src/matrix/sdk/http-client.test.ts b/extensions/matrix/src/matrix/sdk/http-client.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/http-client.test.ts rename to extensions/matrix/src/matrix/sdk/http-client.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/http-client.ts b/extensions/matrix/src/matrix/sdk/http-client.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/http-client.ts rename to extensions/matrix/src/matrix/sdk/http-client.ts diff --git a/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts b/extensions/matrix/src/matrix/sdk/idb-persistence.ts similarity index 93% rename from extensions/matrix-js/src/matrix/sdk/idb-persistence.ts rename to extensions/matrix/src/matrix/sdk/idb-persistence.ts index 8bfd8042cee..7686b920662 100644 --- a/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts +++ b/extensions/matrix/src/matrix/sdk/idb-persistence.ts @@ -127,18 +127,8 @@ function resolveDefaultIdbSnapshotPath(): string { return path.join(stateDir, "matrix", "crypto-idb-snapshot.json"); } -function resolveLegacyIdbSnapshotPath(): string { - const stateDir = - process.env.OPENCLAW_STATE_DIR || - process.env.MOLTBOT_STATE_DIR || - path.join(process.env.HOME || "/tmp", ".openclaw"); - return path.join(stateDir, "credentials", "matrix-js", "crypto-idb-snapshot.json"); -} - export async function restoreIdbFromDisk(snapshotPath?: string): Promise { - const candidatePaths = snapshotPath - ? [snapshotPath] - : [resolveDefaultIdbSnapshotPath(), resolveLegacyIdbSnapshotPath()]; + const candidatePaths = snapshotPath ? [snapshotPath] : [resolveDefaultIdbSnapshotPath()]; for (const resolvedPath of candidatePaths) { try { const data = fs.readFileSync(resolvedPath, "utf8"); diff --git a/extensions/matrix-js/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts similarity index 96% rename from extensions/matrix-js/src/matrix/sdk/logger.ts rename to extensions/matrix/src/matrix/sdk/logger.ts index 61831a37815..e1f1b3828fa 100644 --- a/extensions/matrix-js/src/matrix/sdk/logger.ts +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -1,5 +1,5 @@ import { format } from "node:util"; -import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix-js"; +import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../../runtime.js"; export type Logger = { @@ -16,7 +16,7 @@ export function noop(): void { function resolveRuntimeLogger(module: string): RuntimeLogger | null { try { - return getMatrixRuntime().logging.getChildLogger({ module: `matrix-js:${module}` }); + return getMatrixRuntime().logging.getChildLogger({ module: `matrix:${module}` }); } catch { return null; } diff --git a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts rename to extensions/matrix/src/matrix/sdk/recovery-key-store.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix/src/matrix/sdk/recovery-key-store.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts rename to extensions/matrix/src/matrix/sdk/recovery-key-store.ts diff --git a/extensions/matrix-js/src/matrix/sdk/transport.ts b/extensions/matrix/src/matrix/sdk/transport.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/transport.ts rename to extensions/matrix/src/matrix/sdk/transport.ts diff --git a/extensions/matrix-js/src/matrix/sdk/types.ts b/extensions/matrix/src/matrix/sdk/types.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/types.ts rename to extensions/matrix/src/matrix/sdk/types.ts diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix/src/matrix/sdk/verification-manager.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts rename to extensions/matrix/src/matrix/sdk/verification-manager.test.ts diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.ts b/extensions/matrix/src/matrix/sdk/verification-manager.ts similarity index 100% rename from extensions/matrix-js/src/matrix/sdk/verification-manager.ts rename to extensions/matrix/src/matrix/sdk/verification-manager.ts diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index dabe915b388..a303126dd76 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -2,56 +2,25 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../runtime.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", () => ({ - ConsoleLogger: class { - trace = vi.fn(); - debug = vi.fn(); - info = vi.fn(); - warn = vi.fn(); - error = vi.fn(); - }, - LogService: { - setLogger: vi.fn(), - }, - 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"), fileName: "photo.png", contentType: "image/png", kind: "image", }); -const runtimeLoadConfigMock = vi.fn(() => ({})); -const mediaKindFromMimeMock = vi.fn(() => "image"); -const isVoiceCompatibleAudioMock = vi.fn(() => false); const getImageMetadataMock = vi.fn().mockResolvedValue(null); const resizeToJpegMock = vi.fn(); const runtimeStub = { config: { - loadConfig: runtimeLoadConfigMock, + loadConfig: () => ({}), }, 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: { @@ -66,32 +35,39 @@ const runtimeStub = { } as unknown as PluginRuntime; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; -let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes; +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 }; + } 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")); + ({ 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", + }); + getImageMetadataMock.mockReset().mockResolvedValue(null); + resizeToJpegMock.mockReset(); setMatrixRuntime(runtimeStub); }); @@ -155,72 +131,82 @@ 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, + }); }); }); describe("sendMessageMatrix threads", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + ({ voteMatrixPoll } = await import("./actions/polls.js")); + }); + beforeEach(() => { vi.clearAllMocks(); - runtimeLoadConfigMock.mockReset(); - runtimeLoadConfigMock.mockReturnValue({}); setMatrixRuntime(runtimeStub); }); @@ -248,79 +234,113 @@ describe("sendMessageMatrix threads", () => { }); }); -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, - }, - }, - }); 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" }], }, }, }); - setMatrixRuntime(runtimeStub); + + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 2, + }), + ).rejects.toThrow("out of range"); }); - it("uses provided cfg and skips runtime loadConfig", () => { - const providedCfg = { - channels: { - matrix: { - mediaMaxMb: 3, + 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" }, + ], }, }, - }; + }); - const maxBytes = resolveMediaMaxBytes(undefined, providedCfg as any); - - expect(maxBytes).toBe(3 * 1024 * 1024); - expect(runtimeLoadConfigMock).not.toHaveBeenCalled(); + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndexes: [1, 2], + }), + ).rejects.toThrow("at most 1 selection"); }); - it("falls back to runtime loadConfig when cfg is omitted", () => { - const maxBytes = resolveMediaMaxBytes(); + it("rejects non-poll events before sending a response", async () => { + const { client, getEvent, sendEvent } = makeClient(); + getEvent.mockResolvedValue({ + type: "m.room.message", + content: { body: "hello" }, + }); - expect(maxBytes).toBe(9 * 1024 * 1024); - expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1); + await expect( + voteMatrixPoll("room:!room:example", "$poll", { + client, + optionIndex: 1, + }), + ).rejects.toThrow("is not a Matrix poll start event"); + expect(sendEvent).not.toHaveBeenCalled(); }); }); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 6aea822f882..6ae99035e9c 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,8 +1,8 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "openclaw/plugin-sdk/matrix"; import { getMatrixRuntime } from "../runtime.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; -import { enqueueSend } from "./send-queue.js"; +import { buildMatrixReactionContent } from "./reaction-common.js"; +import type { MatrixClient } from "./sdk.js"; import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; import { buildReplyRelation, @@ -21,11 +21,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,6 +32,28 @@ const getCore = () => getMatrixRuntime(); export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; export { resolveMatrixRoomId } from "./send/targets.js"; +type MatrixClientResolveOpts = { + client?: MatrixClient; + timeoutMs?: number; + accountId?: string | null; +}; + +function 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, timeoutMs: opts.timeoutMs, accountId: opts.accountId }; +} + export async function sendMessageMatrix( to: string, message: string, @@ -47,109 +67,109 @@ export async function sendMessageMatrix( 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 () => { - const tableMode = getCore().channel.text.resolveMarkdownTableMode({ - cfg, - channel: "matrix", - accountId: opts.accountId, - }); - const convertedMessage = getCore().channel.text.convertMarkdownTables( - trimmedMessage, - tableMode, - ); - const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); - const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); - const chunks = getCore().channel.text.chunkMarkdownTextWithMode( - convertedMessage, - chunkLimit, - chunkMode, - ); - const threadId = normalizeThreadId(opts.threadId); - const relation = threadId - ? buildThreadRelation(threadId, opts.replyToId) - : buildReplyRelation(opts.replyToId); - const sendContent = async (content: MatrixOutboundContent) => { - // @vector-im/matrix-bot-sdk uses sendMessage differently - const eventId = await client.sendMessage(roomId, content); - return eventId; - }; + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; - let lastMessageId = ""; - if (opts.mediaUrl) { - const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg); - const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { - contentType: media.contentType, - filename: media.fileName, - }); - const durationMs = await resolveMediaDurationMs({ - buffer: media.buffer, - contentType: media.contentType, - fileName: media.fileName, - kind: media.kind ?? "unknown", - }); - const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); - const { useVoice } = resolveMatrixVoiceDecision({ - wantsVoice: opts.audioAsVoice === true, - contentType: media.contentType, - fileName: media.fileName, - }); - const msgtype = useVoice ? MsgType.Audio : baseMsgType; - const isImage = msgtype === MsgType.Image; - const imageInfo = isImage - ? await prepareImageInfo({ buffer: media.buffer, client }) - : undefined; - const [firstChunk, ...rest] = chunks; - const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); - const content = buildMediaContent({ - msgtype, - body, - url: uploaded.url, - file: uploaded.file, - filename: media.fileName, - mimetype: media.contentType, - size: media.buffer.byteLength, - durationMs, - relation, - isVoice: useVoice, - imageInfo, - }); + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(opts.accountId); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind ?? "unknown", + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ + buffer: media.buffer, + client, + encrypted: Boolean(uploaded.file), + }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) { + continue; + } + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) { + continue; + } + const content = buildTextContent(text, relation); const eventId = await sendContent(content); lastMessageId = eventId ?? lastMessageId; - const textChunks = useVoice ? chunks : rest; - const followupRelation = threadId ? relation : undefined; - for (const chunk of textChunks) { - const text = chunk.trim(); - if (!text) { - continue; - } - const followup = buildTextContent(text, followupRelation); - const followupEventId = await sendContent(followup); - lastMessageId = followupEventId ?? lastMessageId; - } - } else { - for (const chunk of chunks.length ? chunks : [""]) { - const text = chunk.trim(); - if (!text) { - continue; - } - const content = buildTextContent(text, relation); - const eventId = await sendContent(content); - lastMessageId = eventId ?? lastMessageId; - } } + } - return { - messageId: lastMessageId || "unknown", - roomId, - }; - }); + return { + messageId: lastMessageId || "unknown", + roomId, + }; } finally { if (stopOnDone) { client.stop(); @@ -172,7 +192,6 @@ export async function sendPollMatrix( client: opts.client, timeoutMs: opts.timeoutMs, accountId: opts.accountId, - cfg: opts.cfg, }); try { @@ -182,7 +201,6 @@ export async function sendPollMatrix( 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 { @@ -241,23 +259,17 @@ 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 clientOpts = normalizeMatrixClientResolveOpts(opts); const { client: resolved, stopOnDone } = await resolveMatrixClient({ - client, + client: clientOpts.client, + timeoutMs: clientOpts.timeoutMs, + accountId: clientOpts.accountId ?? undefined, }); try { const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); - const reaction: ReactionEventContent = { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: messageId, - key: emoji, - }, - }; + const reaction = buildMatrixReactionContent(messageId, emoji); await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); } finally { if (stopOnDone) { diff --git a/extensions/matrix-js/src/matrix/send/client.test.ts b/extensions/matrix/src/matrix/send/client.test.ts similarity index 100% rename from extensions/matrix-js/src/matrix/send/client.test.ts rename to extensions/matrix/src/matrix/send/client.test.ts diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index e56cf493758..75ff3204846 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,10 +1,9 @@ -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 { getActiveMatrixClient } from "../active-client.js"; +import { createMatrixClient, isBunRuntime, resolveMatrixAuth } from "../client.js"; +import type { MatrixClient } from "../sdk.js"; const getCore = () => getMatrixRuntime(); @@ -14,37 +13,12 @@ export function ensureNodeRuntime() { } } -/** 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 { - 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; +export function resolveMediaMaxBytes(accountId?: string | null): number | undefined { + const cfg = getCore().config.loadConfig() as CoreConfig; + const matrixCfg = resolveMatrixAccountConfig({ cfg, accountId }); + const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined; + if (typeof mediaMaxMb === "number") { + return mediaMaxMb * 1024 * 1024; } return undefined; } @@ -52,48 +26,28 @@ export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): numb export async function resolveMatrixClient(opts: { client?: MatrixClient; timeoutMs?: number; - accountId?: string; - cfg?: CoreConfig; + accountId?: string | null; }): 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); + const active = getActiveMatrixClient(opts.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, + const auth = await resolveMatrixAuth({ accountId: opts.accountId }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + accountId: opts.accountId, + autoBootstrapCrypto: false, }); + await client.prepareForOneOff(); return { client, stopOnDone: true }; } 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..3e3610cd300 100644 --- a/extensions/matrix/src/matrix/send/targets.test.ts +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -1,5 +1,5 @@ -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; diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index d4d4e2b6e0d..8d358ecf825 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "../sdk.js"; import { EventType, type MatrixDirectAccountData } from "./types.js"; function normalizeTarget(raw: string): string { @@ -35,9 +35,11 @@ async function persistDirectRoom( userId: string, roomId: string, ): Promise { - let directContent: MatrixDirectAccountData | null = null; + let directContent: MatrixDirectAccountData | undefined; try { - directContent = await client.getAccountData(EventType.Direct); + directContent = (await client.getAccountData(EventType.Direct)) as + | MatrixDirectAccountData + | undefined; } catch { // Ignore fetch errors and fall back to an empty map. } diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index e3aec1dcae7..d597255a593 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -1,3 +1,8 @@ +import { + MATRIX_ANNOTATION_RELATION_TYPE, + MATRIX_REACTION_EVENT_TYPE, + type MatrixReactionEventContent, +} from "../reaction-common.js"; import type { DimensionalFileInfo, EncryptedFile, @@ -6,7 +11,7 @@ import type { TextualMessageEventContent, TimedFileInfo, VideoFileInfo, -} from "@vector-im/matrix-bot-sdk"; +} from "../sdk.js"; // Message types export const MsgType = { @@ -20,7 +25,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 +33,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 +76,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,8 +84,7 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { - cfg?: import("../../types.js").CoreConfig; - client?: import("@vector-im/matrix-bot-sdk").MatrixClient; + client?: import("../sdk.js").MatrixClient; mediaUrl?: string; accountId?: string; replyToId?: string; diff --git a/extensions/matrix-js/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts similarity index 95% rename from extensions/matrix-js/src/matrix/thread-bindings.test.ts rename to extensions/matrix/src/matrix/thread-bindings.test.ts index 7e680ac6342..23f4fa5fd17 100644 --- a/extensions/matrix-js/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getSessionBindingService, @@ -61,7 +61,7 @@ describe("matrix thread bindings", () => { targetSessionKey: "agent:ops:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "!room:example", }, @@ -76,7 +76,7 @@ describe("matrix thread bindings", () => { accountId: "ops", }); expect(binding.conversation).toEqual({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$root", parentConversationId: "!room:example", @@ -101,7 +101,7 @@ describe("matrix thread bindings", () => { targetSessionKey: "agent:ops:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$thread", parentConversationId: "!room:example", @@ -119,7 +119,7 @@ describe("matrix thread bindings", () => { }); expect( getSessionBindingService().resolveByConversation({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$thread", parentConversationId: "!room:example", @@ -150,7 +150,7 @@ describe("matrix thread bindings", () => { targetSessionKey: "agent:ops:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$thread", parentConversationId: "!room:example", @@ -167,7 +167,7 @@ describe("matrix thread bindings", () => { expect( getSessionBindingService().resolveByConversation({ - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$thread", parentConversationId: "!room:example", @@ -196,7 +196,7 @@ describe("matrix thread bindings", () => { targetSessionKey: "agent:ops:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "ops", conversationId: "$thread", parentConversationId: "!room:example", diff --git a/extensions/matrix-js/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts similarity index 99% rename from extensions/matrix-js/src/matrix/thread-bindings.ts rename to extensions/matrix/src/matrix/thread-bindings.ts index 0004b026c77..897b1c2d629 100644 --- a/extensions/matrix-js/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -8,7 +8,7 @@ import { writeJsonFileAtomically, type BindingTargetKind, type SessionBindingRecord, -} from "openclaw/plugin-sdk/matrix-js"; +} from "openclaw/plugin-sdk/matrix"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import type { MatrixAuth } from "./client/types.js"; import type { MatrixClient } from "./sdk.js"; @@ -150,7 +150,7 @@ function toSessionBindingRecord( targetSessionKey: record.targetSessionKey, targetKind: toSessionBindingTargetKind(record.targetKind), conversation: { - channel: "matrix-js", + channel: "matrix", accountId: record.accountId, conversationId: record.conversationId, parentConversationId: record.parentConversationId, @@ -443,7 +443,7 @@ export async function createMatrixThreadBindingManager(params: { persistTimer = null; } unregisterSessionBindingAdapter({ - channel: "matrix-js", + channel: "matrix", accountId: params.accountId, }); if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId) === manager) { @@ -483,7 +483,7 @@ export async function createMatrixThreadBindingManager(params: { }; registerSessionBindingAdapter({ - channel: "matrix-js", + channel: "matrix", accountId: params.accountId, capabilities: { placements: ["current", "child"], bindSupported: true, unbindSupported: true }, bind: async (input) => { diff --git a/extensions/matrix-js/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts similarity index 91% rename from extensions/matrix-js/src/onboarding.test.ts rename to extensions/matrix/src/onboarding.test.ts index 01319f86dc1..6a809616027 100644 --- a/extensions/matrix-js/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix-js"; +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"; @@ -53,7 +53,7 @@ describe("matrix onboarding", () => { const prompter = { note: vi.fn(async () => {}), select: vi.fn(async ({ message }: { message: string }) => { - if (message === "Matrix-js already configured. What do you want to do?") { + if (message === "Matrix already configured. What do you want to do?") { return "add-account"; } if (message === "Matrix auth method") { @@ -79,7 +79,7 @@ describe("matrix onboarding", () => { const result = await matrixOnboardingAdapter.configureInteractive!({ cfg: { channels: { - "matrix-js": { + matrix: { accounts: { default: { homeserver: "https://matrix.main.example.org", @@ -96,17 +96,17 @@ describe("matrix onboarding", () => { shouldPromptAccountIds: true, forceAllowFrom: false, configured: true, - label: "Matrix-js", + label: "Matrix", }); expect(result).not.toBe("skip"); if (result !== "skip") { expect(result.accountId).toBe("ops"); - expect(result.cfg.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({ + expect(result.cfg.channels?.["matrix"]?.accounts?.ops).toMatchObject({ enabled: true, }); - expect(result.cfg.channels?.["matrix-js"]?.accounts?.ops?.homeserver).toBeUndefined(); - expect(result.cfg.channels?.["matrix-js"]?.accounts?.ops?.accessToken).toBeUndefined(); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops?.homeserver).toBeUndefined(); + expect(result.cfg.channels?.["matrix"]?.accounts?.ops?.accessToken).toBeUndefined(); } expect( confirmMessages.some((message) => @@ -150,7 +150,7 @@ describe("matrix onboarding", () => { shouldPromptAccountIds: false, forceAllowFrom: false, configured: false, - label: "Matrix-js", + label: "Matrix", }), ).rejects.toThrow("stop-after-help"); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 642522dbc50..2b995307dfa 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -1,21 +1,30 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; import { addWildcardAllowFrom, - buildSingleChannelSecretPromptState, - formatResolvedUnresolvedNote, formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, - promptSingleChannelSecretInput, + normalizeAccountId, + promptAccountId, promptChannelAccessConfig, - setTopLevelChannelGroupPolicy, - type SecretInput, + type RuntimeEnv, type ChannelOnboardingAdapter, type ChannelOnboardingDmPolicy, type WizardPrompter, } from "openclaw/plugin-sdk/matrix"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; -import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + resolveMatrixAccountConfig, +} from "./matrix/accounts.js"; +import { + getMatrixScopedEnvVarNames, + hasReadyMatrixEnvAuth, + resolveScopedMatrixEnvConfig, +} from "./matrix/client.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; import type { CoreConfig } from "./types.js"; @@ -24,15 +33,15 @@ const channel = "matrix" as const; function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = - policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; + policy === "open" ? addWildcardAllowFrom(cfg.channels?.["matrix"]?.dm?.allowFrom) : undefined; return { ...cfg, channels: { ...cfg.channels, matrix: { - ...cfg.channels?.matrix, + ...cfg.channels?.["matrix"], dm: { - ...cfg.channels?.matrix?.dm, + ...cfg.channels?.["matrix"]?.dm, policy, ...(allowFrom ? { allowFrom } : {}), }, @@ -45,9 +54,10 @@ 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).", + "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.", + "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", @@ -57,9 +67,10 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { async function promptMatrixAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; + accountId?: string; }): Promise { const { cfg, prompter } = params; - const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + const existingAllowFrom = cfg.channels?.["matrix"]?.dm?.allowFrom ?? []; const account = resolveMatrixAccount({ cfg }); const canResolve = Boolean(account.configured); @@ -131,10 +142,10 @@ async function promptMatrixAllowFrom(params: { channels: { ...cfg.channels, matrix: { - ...cfg.channels?.matrix, + ...cfg.channels?.["matrix"], enabled: true, dm: { - ...cfg.channels?.matrix?.dm, + ...cfg.channels?.["matrix"]?.dm, policy: "allowlist", allowFrom: unique, }, @@ -145,12 +156,17 @@ async function promptMatrixAllowFrom(params: { } function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "matrix", - groupPolicy, - enabled: true, - }) as CoreConfig; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.["matrix"], + enabled: true, + groupPolicy, + }, + }, + }; } function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { @@ -160,7 +176,7 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { channels: { ...cfg.channels, matrix: { - ...cfg.channels?.matrix, + ...cfg.channels?.["matrix"], enabled: true, groups, }, @@ -173,11 +189,291 @@ const dmPolicy: ChannelOnboardingDmPolicy = { channel, policyKey: "channels.matrix.dm.policy", allowFromKey: "channels.matrix.dm.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.["matrix"]?.dm?.policy ?? "pairing", setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), promptAllowFrom: promptMatrixAllowFrom, }; +type MatrixConfigureIntent = "update" | "add-account"; + +async function runMatrixConfigure(params: { + cfg: CoreConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + forceAllowFrom: boolean; + accountOverrides?: Partial>; + 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"); + } + 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 scopedEnv = resolveScopedMatrixEnvConfig(accountId, process.env); + const defaultScopedEnv = resolveScopedMatrixEnvConfig(DEFAULT_ACCOUNT_ID, process.env); + const globalEnv = { + homeserver: process.env.MATRIX_HOMESERVER?.trim() ?? "", + userId: process.env.MATRIX_USER_ID?.trim() ?? "", + accessToken: process.env.MATRIX_ACCESS_TOKEN?.trim() || undefined, + password: process.env.MATRIX_PASSWORD?.trim() || undefined, + }; + const scopedReady = hasReadyMatrixEnvAuth(scopedEnv); + const defaultScopedReady = hasReadyMatrixEnvAuth(defaultScopedEnv); + const globalReady = hasReadyMatrixEnvAuth(globalEnv); + const envReady = + scopedReady || (accountId === DEFAULT_ACCOUNT_ID && (defaultScopedReady || globalReady)); + const envHomeserver = + scopedEnv.homeserver || + (accountId === DEFAULT_ACCOUNT_ID + ? defaultScopedEnv.homeserver || globalEnv.homeserver + : undefined); + const envUserId = + scopedEnv.userId || + (accountId === DEFAULT_ACCOUNT_ID ? defaultScopedEnv.userId || globalEnv.userId : undefined); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const scopedEnvNames = getMatrixScopedEnvVarNames(accountId); + const envSourceHint = + accountId === DEFAULT_ACCOUNT_ID + ? "MATRIX_* or MATRIX_DEFAULT_*" + : `${scopedEnvNames.homeserver} (+ auth vars)`; + const useEnv = await params.prompter.confirm({ + message: `Matrix env vars detected (${envSourceHint}). Use env values?`, + initialValue: true, + }); + if (useEnv) { + next = updateMatrixAccountConfig(next, accountId, { enabled: true }); + if (params.forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter }); + } + return { cfg: next, accountId }; + } + } + + const homeserver = String( + await params.prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!/^https?:\/\//i.test(raw)) { + return "Use a full URL (https://...)"; + } + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = 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 }); + } + + const existingGroups = next.channels?.["matrix"]?.groups ?? next.channels?.["matrix"]?.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter: params.prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.["matrix"]?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await params.prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await params.prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixGroupRooms(next, roomKeys); + } + } + + return { cfg: next, accountId }; +} + export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { channel, getStatus: async ({ cfg }) => { @@ -190,273 +486,74 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { statusLines: [ `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, ], - selectionHint: !sdkReady - ? "install @vector-im/matrix-bot-sdk" - : configured - ? "configured" - : "needs auth", + selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth", }; }, - configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { - let next = cfg as CoreConfig; - await ensureMatrixSdkInstalled({ + configure: async ({ + cfg, + runtime, + prompter, + forceAllowFrom, + accountOverrides, + shouldPromptAccountIds, + }) => + await runMatrixConfigure({ + cfg: cfg as CoreConfig, 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 = { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - }; - 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()) { - // Ask auth method FIRST before asking for user ID - 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(); - // With access token, we can fetch the userId automatically - don't prompt for it - // The client.ts will use whoami() to get it - userId = ""; - } else { - // Password auth requires user ID upfront - 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: "matrix", - 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(); - - // Ask about E2EE encryption - 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: password, - deviceName: deviceName || undefined, - encryption: enableEncryption || undefined, - }, - }, - }; - - if (forceAllowFrom) { - next = await promptMatrixAllowFrom({ cfg: next, prompter }); - } - - const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; - const accessConfig = await promptChannelAccessConfig({ prompter, - label: "Matrix rooms", - currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", - currentEntries: Object.keys(existingGroups ?? {}), - placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(existingGroups), - }); - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setMatrixGroupPolicy(next, accessConfig.policy); - } else { - let roomKeys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolvedIds: string[] = []; - const unresolved: string[] = []; - for (const entry of accessConfig.entries) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); - if (cleaned.startsWith("!") && cleaned.includes(":")) { - resolvedIds.push(cleaned); - continue; - } - const matches = await listMatrixDirectoryGroupsLive({ - cfg: next, - query: trimmed, - limit: 10, - }); - const exact = matches.find( - (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), - ); - const best = exact ?? matches[0]; - if (best?.id) { - resolvedIds.push(best.id); - } else { - unresolved.push(entry); - } - } - roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Matrix rooms"); - } - } catch (err) { - await prompter.note( - `Room lookup failed; keeping entries as typed. ${String(err)}`, - "Matrix rooms", - ); - } - } - next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixGroupRooms(next, roomKeys); - } + 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", + }); } - - return { cfg: next }; + 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", + }); }, dmPolicy, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { ...(cfg as CoreConfig).channels, - matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false }, + matrix: { ...(cfg as CoreConfig).channels?.["matrix"], enabled: false }, }, }), }; diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index be4f8d3426d..14a6723f909 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -7,12 +7,11 @@ export const matrixOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => { + sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { - cfg, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, accountId: accountId ?? undefined, @@ -23,12 +22,11 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { + sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => { const send = deps?.sendMatrix ?? sendMessageMatrix; const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await send(to, text, { - cfg, mediaUrl, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, @@ -40,11 +38,10 @@ export const matrixOutbound: ChannelOutboundAdapter = { roomId: result.roomId, }; }, - sendPoll: async ({ cfg, to, poll, threadId, accountId }) => { + sendPoll: async ({ to, poll, threadId, accountId }) => { const resolvedThreadId = threadId !== undefined && threadId !== null ? String(threadId) : undefined; const result = await sendPollMatrix(to, poll, { - cfg, threadId: resolvedThreadId, accountId: accountId ?? undefined, }); diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 10dff313a2e..a967b537618 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -64,4 +64,29 @@ describe("resolveMatrixTargets (users)", () => { expect(result?.id).toBe("!two:example.org"); expect(result?.note).toBe("multiple matches; chose first"); }); + + it("reuses directory lookups for duplicate inputs", async () => { + vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue([ + { kind: "user", id: "@alice:example.org", name: "Alice" }, + ]); + vi.mocked(listMatrixDirectoryGroupsLive).mockResolvedValue([ + { kind: "group", id: "!team:example.org", name: "Team", handle: "#team" }, + ]); + + const userResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["Alice", "Alice"], + kind: "user", + }); + const groupResults = await resolveMatrixTargets({ + cfg: {}, + inputs: ["#team", "#team"], + kind: "group", + }); + + expect(userResults.every((entry) => entry.resolved)).toBe(true); + expect(groupResults.every((entry) => entry.resolved)).toBe(true); + expect(listMatrixDirectoryPeersLive).toHaveBeenCalledTimes(1); + expect(listMatrixDirectoryGroupsLive).toHaveBeenCalledTimes(1); + }); }); diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts index 2c179492cb0..d5c36d3de2b 100644 --- a/extensions/matrix/src/resolve-targets.ts +++ b/extensions/matrix/src/resolve-targets.ts @@ -1,4 +1,3 @@ -import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/compat"; import type { ChannelDirectoryEntry, ChannelResolveKind, @@ -72,54 +71,79 @@ export async function resolveMatrixTargets(params: { 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(); + + const readUserMatches = async (query: string): Promise => { + const cached = userLookupCache.get(query); + if (cached) { + return cached; + } + const matches = await listMatrixDirectoryPeersLive({ + cfg: params.cfg, + query, + limit: 5, + }); + userLookupCache.set(query, matches); + return matches; + }; + + const readGroupMatches = async (query: string): Promise => { + const cached = groupLookupCache.get(query); + if (cached) { + return cached; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query, + limit: 5, + }); + groupLookupCache.set(query, matches); + return matches; + }; + + for (const input of params.inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (params.kind === "user") { + if (trimmed.startsWith("@") && trimmed.includes(":")) { + results.push({ input, resolved: true, id: trimmed }); + continue; } try { - const matches = await listMatrixDirectoryGroupsLive({ - cfg: params.cfg, - query: trimmed, - limit: 5, - }); - const best = pickBestGroupMatch(matches, trimmed); - return { + const matches = await readUserMatches(trimmed); + 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; + } + try { + const matches = await readGroupMatches(trimmed); + const best = pickBestGroupMatch(matches, trimmed); + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; } diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts index eefce7b910a..4d94aacf99d 100644 --- a/extensions/matrix/src/runtime.ts +++ b/extensions/matrix/src/runtime.ts @@ -1,6 +1,14 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; import type { PluginRuntime } from "openclaw/plugin-sdk/matrix"; -const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } = - createPluginRuntimeStore("Matrix runtime not initialized"); -export { getMatrixRuntime, setMatrixRuntime }; +let runtime: PluginRuntime | null = null; + +export function setMatrixRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMatrixRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Matrix runtime not initialized"); + } + return runtime; +} diff --git a/extensions/matrix-js/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts similarity index 91% rename from extensions/matrix-js/src/tool-actions.test.ts rename to extensions/matrix/src/tool-actions.test.ts index dc520503822..415e8cbc088 100644 --- a/extensions/matrix-js/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -108,7 +108,7 @@ describe("handleMatrixAction pollVote", () => { messageId: "$msg", emoji: "👍", }, - { channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig, + { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig, ); expect(mocks.reactMatrixMessage).toHaveBeenCalledWith("!room:example", "$msg", "👍", { @@ -126,7 +126,7 @@ describe("handleMatrixAction pollVote", () => { emoji: "👍", remove: true, }, - { channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig, + { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig, ); expect(mocks.removeMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { @@ -144,7 +144,7 @@ describe("handleMatrixAction pollVote", () => { message_id: "$msg", limit: "5", }, - { channels: { "matrix-js": { actions: { reactions: true } } } } as CoreConfig, + { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig, ); expect(mocks.listMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { @@ -166,7 +166,7 @@ describe("handleMatrixAction pollVote", () => { content: "hello", threadId: "$thread", }, - { channels: { "matrix-js": { actions: { messages: true } } } } as CoreConfig, + { channels: { matrix: { actions: { messages: true } } } } as CoreConfig, ); expect(mocks.sendMatrixMessage).toHaveBeenCalledWith("room:!room:example", "hello", { @@ -184,7 +184,7 @@ describe("handleMatrixAction pollVote", () => { accountId: "ops", roomId: "!room:example", }, - { channels: { "matrix-js": { actions: { pins: true } } } } as CoreConfig, + { channels: { matrix: { actions: { pins: true } } } } as CoreConfig, ); expect(mocks.listMatrixPins).toHaveBeenCalledWith("!room:example", { @@ -200,7 +200,7 @@ describe("handleMatrixAction pollVote", () => { userId: "@u:example", roomId: "!room:example", }, - { channels: { "matrix-js": { actions: { memberInfo: true } } } } as CoreConfig, + { channels: { matrix: { actions: { memberInfo: true } } } } as CoreConfig, ); await handleMatrixAction( { @@ -208,7 +208,7 @@ describe("handleMatrixAction pollVote", () => { accountId: "ops", roomId: "!room:example", }, - { channels: { "matrix-js": { actions: { channelInfo: true } } } } as CoreConfig, + { channels: { matrix: { actions: { channelInfo: true } } } } as CoreConfig, ); expect(mocks.getMatrixMemberInfo).toHaveBeenCalledWith("@u:example", { diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 28c8d5676d1..4f208705926 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -4,20 +4,39 @@ import { jsonResult, readNumberParam, readReactionParams, + readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/matrix"; import { + bootstrapMatrixVerification, + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, deleteMatrixMessage, editMatrixMessage, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, + getMatrixRoomKeyBackupStatus, + getMatrixVerificationStatus, getMatrixMemberInfo, getMatrixRoomInfo, + getMatrixVerificationSas, listMatrixPins, listMatrixReactions, + listMatrixVerifications, + mismatchMatrixVerificationSas, pinMatrixMessage, readMatrixMessages, + requestMatrixVerification, + restoreMatrixRoomKeyBackup, removeMatrixReactions, + scanMatrixVerificationQr, sendMatrixMessage, + startMatrixVerification, unpinMatrixMessage, + voteMatrixPoll, + verifyMatrixRecoveryKey, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; import type { CoreConfig } from "./types.js"; @@ -25,6 +44,26 @@ import type { CoreConfig } from "./types.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +const pollActions = new Set(["pollVote"]); +const verificationActions = new Set([ + "encryptionStatus", + "verificationList", + "verificationRequest", + "verificationAccept", + "verificationCancel", + "verificationStart", + "verificationGenerateQr", + "verificationScanQr", + "verificationSas", + "verificationConfirm", + "verificationMismatch", + "verificationConfirmQr", + "verificationStatus", + "verificationBootstrap", + "verificationRecoveryKey", + "verificationBackupStatus", + "verificationBackupRestore", +]); function readRoomId(params: Record, required = true): string { const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); @@ -37,12 +76,61 @@ 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, ): Promise> { const action = readStringParam(params, "action", { required: true }); - const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions); + const accountId = readStringParam(params, "accountId") ?? undefined; + const isActionEnabled = createActionGate(cfg.channels?.["matrix"]?.actions); + const clientOpts = accountId ? { accountId } : {}; if (reactionActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -56,17 +144,43 @@ export async function handleMatrixAction( }); if (remove || isEmpty) { const result = await removeMatrixReactions(roomId, messageId, { + accountId, emoji: remove ? emoji : undefined, }); return jsonResult({ ok: true, removed: result.removed }); } - await reactMatrixMessage(roomId, messageId, emoji); + await reactMatrixMessage(roomId, messageId, emoji, { accountId }); 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, { + accountId, + limit: limit ?? undefined, + }); return jsonResult({ ok: true, reactions }); } + if (pollActions.has(action)) { + const roomId = readRoomId(params); + const pollId = readStringParam(params, "pollId", { required: true }); + const optionId = readStringParam(params, "pollOptionId"); + const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true }); + const optionIds = [ + ...(readStringArrayParam(params, "pollOptionIds") ?? []), + ...(optionId ? [optionId] : []), + ]; + const optionIndexes = [ + ...readNumericArrayParam(params, "pollOptionIndexes", { integer: true }), + ...(optionIndex !== undefined ? [optionIndex] : []), + ]; + const result = await voteMatrixPoll(roomId, pollId, { + accountId, + optionIds, + optionIndexes, + }); + return jsonResult({ ok: true, result }); + } + if (messageActions.has(action)) { if (!isActionEnabled("messages")) { throw new Error("Matrix messages are disabled."); @@ -86,6 +200,7 @@ export async function handleMatrixAction( mediaUrl: mediaUrl ?? undefined, replyToId: replyToId ?? undefined, threadId: threadId ?? undefined, + ...clientOpts, }); return jsonResult({ ok: true, result }); } @@ -93,14 +208,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 +230,7 @@ export async function handleMatrixAction( limit: limit ?? undefined, before: before ?? undefined, after: after ?? undefined, + ...clientOpts, }); return jsonResult({ ok: true, ...result }); } @@ -127,15 +246,15 @@ 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 }); } @@ -147,6 +266,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 +276,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, accountId }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationStatus") { + const includeRecoveryKey = params.includeRecoveryKey === true; + const status = await getMatrixVerificationStatus({ includeRecoveryKey, accountId }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationBootstrap") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await bootstrapMatrixVerification({ + recoveryKey: recoveryKey ?? undefined, + forceResetCrossSigning: params.forceResetCrossSigning === true, + accountId, + }); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationRecoveryKey") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await verifyMatrixRecoveryKey( + readStringParam({ recoveryKey }, "recoveryKey", { required: true, trim: false }), + { accountId }, + ); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationBackupStatus") { + const status = await getMatrixRoomKeyBackupStatus({ accountId }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationBackupRestore") { + const recoveryKey = + readStringParam(params, "recoveryKey", { trim: false }) ?? + readStringParam(params, "key", { trim: false }); + const result = await restoreMatrixRoomKeyBackup({ + recoveryKey: recoveryKey ?? undefined, + accountId, + }); + return jsonResult({ ok: result.success, result }); + } + if (action === "verificationList") { + const verifications = await listMatrixVerifications({ accountId }); + return jsonResult({ ok: true, verifications }); + } + if (action === "verificationRequest") { + const userId = readStringParam(params, "userId"); + const deviceId = readStringParam(params, "deviceId"); + const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + const ownUser = typeof params.ownUser === "boolean" ? params.ownUser : undefined; + const verification = await requestMatrixVerification({ + ownUser, + userId: userId ?? undefined, + deviceId: deviceId ?? undefined, + roomId: roomId ?? undefined, + accountId, + }); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationAccept") { + const verification = await acceptMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationCancel") { + const reason = readStringParam(params, "reason"); + const code = readStringParam(params, "code"); + const verification = await cancelMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { reason: reason ?? undefined, code: code ?? undefined, accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationStart") { + const methodRaw = readStringParam(params, "method"); + const method = methodRaw?.trim().toLowerCase(); + if (method && method !== "sas") { + throw new Error( + "Matrix verificationStart only supports method=sas; use verificationGenerateQr/verificationScanQr for QR flows.", + ); + } + const verification = await startMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + { method: "sas", accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationGenerateQr") { + const qr = await generateMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, ...qr }); + } + if (action === "verificationScanQr") { + const qrDataBase64 = + readStringParam(params, "qrDataBase64") ?? + readStringParam(params, "qrData") ?? + readStringParam(params, "qr"); + const verification = await scanMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + readStringParam({ qrDataBase64 }, "qrDataBase64", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationSas") { + const sas = await getMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, sas }); + } + if (action === "verificationConfirm") { + const verification = await confirmMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationMismatch") { + const verification = await mismatchMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationConfirmQr") { + const verification = await confirmMatrixVerificationReciprocateQr( + readStringParam({ requestId }, "requestId", { required: true }), + { accountId }, + ); + return jsonResult({ ok: true, verification }); + } + } + throw new Error(`Unsupported Matrix action: ${action}`); } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index e6feaf9f619..2e9beebea7d 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -37,6 +37,15 @@ export type MatrixActionConfig = { pins?: boolean; memberInfo?: boolean; channelInfo?: boolean; + verification?: boolean; +}; + +export type MatrixThreadBindingsConfig = { + enabled?: boolean; + idleHours?: number; + maxAgeHours?: number; + spawnSubagentSessions?: boolean; + spawnAcpSessions?: boolean; }; /** Per-account Matrix config (excludes the accounts field to prevent recursion). */ @@ -59,9 +68,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,6 +94,18 @@ 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. */ @@ -112,7 +137,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/package.json b/package.json index ccb56f8ddbf..10d49162f12 100644 --- a/package.json +++ b/package.json @@ -132,10 +132,6 @@ "types": "./dist/plugin-sdk/matrix.d.ts", "default": "./dist/plugin-sdk/matrix.js" }, - "./plugin-sdk/matrix-js": { - "types": "./dist/plugin-sdk/matrix-js.d.ts", - "default": "./dist/plugin-sdk/matrix-js.js" - }, "./plugin-sdk/mattermost": { "types": "./dist/plugin-sdk/mattermost.d.ts", "default": "./dist/plugin-sdk/mattermost.js" diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 31520f8d8d5..03ff9dfde8f 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -65,7 +65,6 @@ const requiredSubpathEntries = [ "llm-task", "lobster", "matrix", - "matrix-js", "mattermost", "memory-core", "memory-lancedb", diff --git a/scripts/release-check.ts b/scripts/release-check.ts index f6d5485b4e3..fe2a9a1ea9c 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -69,8 +69,6 @@ const requiredPathGroups = [ "dist/plugin-sdk/lobster.d.ts", "dist/plugin-sdk/matrix.js", "dist/plugin-sdk/matrix.d.ts", - "dist/plugin-sdk/matrix-js.js", - "dist/plugin-sdk/matrix-js.d.ts", "dist/plugin-sdk/mattermost.js", "dist/plugin-sdk/mattermost.d.ts", "dist/plugin-sdk/memory-core.js", diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index 7a12baaf8ac..7053feb19a8 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -31,7 +31,6 @@ const entrypoints = [ "llm-task", "lobster", "matrix", - "matrix-js", "mattermost", "memory-core", "memory-lancedb", diff --git a/src/acp/persistent-bindings.resolve.ts b/src/acp/persistent-bindings.resolve.ts index c33f522573b..bc1fd71b225 100644 --- a/src/acp/persistent-bindings.resolve.ts +++ b/src/acp/persistent-bindings.resolve.ts @@ -21,7 +21,7 @@ import { function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null { const normalized = (value ?? "").trim().toLowerCase(); - if (normalized === "discord" || normalized === "matrix-js" || normalized === "telegram") { + if (normalized === "discord" || normalized === "matrix" || normalized === "telegram") { return normalized; } return null; @@ -146,7 +146,7 @@ export function resolveConfiguredAcpBindingSpecBySessionKey(params: { if (!targetConversationId) { continue; } - if (channel === "discord" || channel === "matrix-js") { + if (channel === "discord" || channel === "matrix") { const spec = toConfiguredBindingSpec({ cfg: params.cfg, channel, @@ -205,7 +205,7 @@ export function resolveConfiguredAcpBindingRecord(params: { return null; } - if (channel === "discord" || channel === "matrix-js") { + if (channel === "discord" || channel === "matrix") { const bindings = listAcpBindings(params.cfg); const resolveChannelBindingForConversation = ( targetConversationId: string, diff --git a/src/acp/persistent-bindings.types.ts b/src/acp/persistent-bindings.types.ts index 3616b7e3a47..c4fd1a7e2e7 100644 --- a/src/acp/persistent-bindings.types.ts +++ b/src/acp/persistent-bindings.types.ts @@ -3,7 +3,7 @@ import type { SessionBindingRecord } from "../infra/outbound/session-binding-ser import { sanitizeAgentId } from "../routing/session-key.js"; import type { AcpRuntimeSessionMode } from "./runtime/types.js"; -export type ConfiguredAcpBindingChannel = "discord" | "matrix-js" | "telegram"; +export type ConfiguredAcpBindingChannel = "discord" | "matrix" | "telegram"; export type ConfiguredAcpBindingSpec = { channel: ConfiguredAcpBindingChannel; diff --git a/src/auto-reply/reply/channel-context.ts b/src/auto-reply/reply/channel-context.ts index fab3625d51c..afe77e32805 100644 --- a/src/auto-reply/reply/channel-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -25,7 +25,7 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean { } export function isMatrixSurface(params: DiscordSurfaceParams): boolean { - return resolveCommandSurfaceChannel(params) === "matrix-js"; + return resolveCommandSurfaceChannel(params) === "matrix"; } export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string { diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index cec949634b5..b466fad5cff 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -63,6 +63,8 @@ function createAcpCommandSessionBindingService() { listBySession: (targetSessionKey: string) => hoisted.sessionBindingListBySessionMock(targetSessionKey), resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), + setIdleTimeoutBySession: vi.fn(async () => []), + setMaxAgeBySession: vi.fn(async () => []), touch: vi.fn(), unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), }; @@ -118,7 +120,7 @@ type FakeBinding = { targetSessionKey: string; targetKind: "subagent" | "session"; conversation: { - channel: "discord" | "telegram"; + channel: "discord" | "matrix" | "telegram"; accountId: string; conversationId: string; parentConversationId?: string; @@ -243,7 +245,7 @@ function createSessionBindingCapabilities() { type AcpBindInput = { targetSessionKey: string; conversation: { - channel?: "discord" | "matrix-js" | "telegram"; + channel?: "discord" | "matrix" | "telegram"; accountId: string; conversationId: string; parentConversationId?: string; @@ -267,9 +269,9 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding { conversationId: nextConversationId, parentConversationId: "parent-1", } - : channel === "matrix-js" + : channel === "matrix" ? { - channel: "matrix-js", + channel: "matrix", accountId: input.conversation.accountId, conversationId: nextConversationId, parentConversationId: input.conversation.parentConversationId ?? "!room:example", @@ -344,9 +346,9 @@ function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseC function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { - Provider: "matrix-js", - Surface: "matrix-js", - OriginatingChannel: "matrix-js", + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", OriginatingTo: "room:!room:example", AccountId: "default", }); @@ -651,9 +653,7 @@ describe("/acp command", () => { it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is not enabled", async () => { const result = await runMatrixRoomAcpCommand("/acp spawn codex --thread auto"); - expect(result?.reply?.text).toContain( - "channels.matrix-js.threadBindings.spawnAcpSessions=true", - ); + expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true"); expect(hoisted.closeMock).toHaveBeenCalledTimes(1); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); @@ -663,7 +663,7 @@ describe("/acp command", () => { ...baseCfg, channels: { ...baseCfg.channels, - "matrix-js": { + matrix: { threadBindings: { enabled: true, spawnAcpSessions: true, @@ -680,7 +680,7 @@ describe("/acp command", () => { expect.objectContaining({ placement: "current", conversation: expect.objectContaining({ - channel: "matrix-js", + channel: "matrix", accountId: "default", conversationId: "$thread-42", parentConversationId: "!room:example", diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 3228cf30d4f..b94e8437185 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -129,16 +129,16 @@ describe("commands-acp context", () => { it("resolves Matrix thread conversation ids from room targets", () => { const params = buildCommandTestParams("/acp status", baseCfg, { - Provider: "matrix-js", - Surface: "matrix-js", - OriginatingChannel: "matrix-js", + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", OriginatingTo: "room:!room:example", MessageThreadId: "$thread-42", AccountId: "work", }); expect(resolveAcpCommandBindingContext(params)).toEqual({ - channel: "matrix-js", + channel: "matrix", accountId: "work", threadId: "$thread-42", conversationId: "$thread-42", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index 6f22cad34c8..007a0bd3c18 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -44,7 +44,7 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined { const channel = resolveAcpCommandChannel(params); - if (channel === "matrix-js") { + if (channel === "matrix") { return resolveMatrixConversationId({ ctx: { MessageThreadId: params.ctx.MessageThreadId, @@ -112,7 +112,7 @@ export function resolveAcpCommandParentConversationId( params: HandleCommandsParams, ): string | undefined { const channel = resolveAcpCommandChannel(params); - if (channel === "matrix-js") { + if (channel === "matrix") { return resolveMatrixParentConversationId({ ctx: { MessageThreadId: params.ctx.MessageThreadId, diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 4d6040373f6..83c6755a89e 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -142,9 +142,9 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "matrix-js", - Surface: "matrix-js", - OriginatingChannel: "matrix-js", + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", OriginatingTo: "room:!room:example", To: "room:!room:example", AccountId: "default", @@ -203,7 +203,7 @@ function createMatrixBinding(overrides?: Partial): Session targetSessionKey: "agent:main:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix-js", + channel: "matrix", accountId: "default", conversationId: "$thread-1", parentConversationId: "!room:example", @@ -241,8 +241,10 @@ describe("/session idle and /session max-age", () => { { targetSessionKey: binding.targetSessionKey, boundAt: Date.now(), - lastActivityAt: Date.now(), - idleTimeoutMs: 2 * 60 * 60 * 1000, + metadata: { + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, }, ]); @@ -287,7 +289,9 @@ describe("/session idle and /session max-age", () => { { targetSessionKey: binding.targetSessionKey, boundAt: Date.now(), - maxAgeMs: 3 * 60 * 60 * 1000, + metadata: { + maxAgeMs: 3 * 60 * 60 * 1000, + }, }, ]); @@ -315,8 +319,10 @@ describe("/session idle and /session max-age", () => { { targetSessionKey: "agent:main:subagent:child", boundAt: Date.now(), - lastActivityAt: Date.now(), - idleTimeoutMs: 2 * 60 * 60 * 1000, + metadata: { + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, }, ]); @@ -347,8 +353,10 @@ describe("/session idle and /session max-age", () => { { targetSessionKey: "agent:main:subagent:child", boundAt, - lastActivityAt: Date.now(), - maxAgeMs: 3 * 60 * 60 * 1000, + metadata: { + lastActivityAt: Date.now(), + maxAgeMs: 3 * 60 * 60 * 1000, + }, }, ]); @@ -376,8 +384,10 @@ describe("/session idle and /session max-age", () => { { targetSessionKey: "agent:main:subagent:child", boundAt: Date.now(), - lastActivityAt: Date.now(), - idleTimeoutMs: 2 * 60 * 60 * 1000, + metadata: { + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, }, ]); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index e35d338d8c3..b01a9ef40b8 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -352,7 +352,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const accountId = resolveChannelAccountId(params); const sessionBindingService = getSessionBindingService(); - const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix-js"; + const channel = onDiscord ? "discord" : onTelegram ? "telegram" : "matrix"; const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; const conversationId = onTelegram diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 13b904bfec6..6f4ee2c08ff 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -107,9 +107,9 @@ function createTelegramTopicCommandParams(commandBody: string) { function createMatrixCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { - Provider: "matrix-js", - Surface: "matrix-js", - OriginatingChannel: "matrix-js", + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", OriginatingTo: "room:!room:example", To: "room:!room:example", AccountId: "default", @@ -239,7 +239,7 @@ describe("/focus, /unfocus, /agents", () => { const cfg = { ...baseCfg, channels: { - "matrix-js": { + matrix: { threadBindings: { spawnAcpSessions: true, }, @@ -253,7 +253,7 @@ describe("/focus, /unfocus, /agents", () => { expect.objectContaining({ placement: "child", conversation: expect.objectContaining({ - channel: "matrix-js", + channel: "matrix", conversationId: "!room:example", parentConversationId: "!room:example", }), @@ -264,9 +264,7 @@ describe("/focus, /unfocus, /agents", () => { it("/focus rejects Matrix child thread creation when spawn config is not enabled", async () => { const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp")); - expect(result?.reply?.text).toContain( - "channels.matrix-js.threadBindings.spawnAcpSessions=true", - ); + expect(result?.reply?.text).toContain("channels.matrix.threadBindings.spawnAcpSessions=true"); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/commands-subagents/action-agents.ts b/src/auto-reply/reply/commands-subagents/action-agents.ts index 99be8abdd08..65097658788 100644 --- a/src/auto-reply/reply/commands-subagents/action-agents.ts +++ b/src/auto-reply/reply/commands-subagents/action-agents.ts @@ -15,7 +15,7 @@ function formatConversationBindingText(params: { if (params.channel === "discord") { return `thread:${params.conversationId}`; } - if (params.channel === "matrix-js") { + if (params.channel === "matrix") { return `thread:${params.conversationId}`; } if (params.channel === "telegram") { @@ -67,9 +67,9 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma channel, conversationId: binding.conversation.conversationId, }) - : channel === "discord" || channel === "telegram" || channel === "matrix-js" + : channel === "discord" || channel === "telegram" || channel === "matrix" ? "unbound" - : "bindings available on discord/matrix-js/telegram"; + : "bindings available on discord/matrix/telegram"; lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`); index += 1; } diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index 425ba7bca49..4b6a78314f3 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -31,7 +31,7 @@ import { } from "./shared.js"; type FocusBindingContext = { - channel: "discord" | "telegram" | "matrix-js"; + channel: "discord" | "telegram" | "matrix"; accountId: string; conversationId: string; parentConversationId?: string; @@ -98,7 +98,7 @@ function resolveFocusBindingContext( }, }); return { - channel: "matrix-js", + channel: "matrix", accountId: resolveChannelAccountId(params), conversationId, ...(parentConversationId ? { parentConversationId } : {}), @@ -114,7 +114,7 @@ export async function handleSubagentsFocusAction( ): Promise { const { params, runs, restTokens } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") { + if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") { return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram."); } @@ -151,7 +151,7 @@ export async function handleSubagentsFocusAction( "⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.", ); } - if (channel === "matrix-js") { + if (channel === "matrix") { return stopWithText("⚠️ Could not resolve a Matrix conversation for /focus."); } return stopWithText("⚠️ Could not resolve a Discord channel for /focus."); @@ -185,7 +185,7 @@ export async function handleSubagentsFocusAction( if (!capabilities.placements.includes(bindingContext.placement)) { return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`); } - if (bindingContext.channel === "matrix-js" && bindingContext.placement === "child") { + if (bindingContext.channel === "matrix" && bindingContext.placement === "child") { const spawnPolicy = resolveThreadBindingSpawnPolicy({ cfg: params.cfg, channel: bindingContext.channel, diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts index 07fe5a7e45b..e6355e8be31 100644 --- a/src/auto-reply/reply/commands-subagents/action-unfocus.ts +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -18,7 +18,7 @@ export async function handleSubagentsUnfocusAction( ): Promise { const { params } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram" && channel !== "matrix-js") { + if (channel !== "discord" && channel !== "telegram" && channel !== "matrix") { return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram."); } @@ -64,7 +64,7 @@ export async function handleSubagentsUnfocusAction( if (channel === "discord") { return stopWithText("⚠️ /unfocus must be run inside a Discord thread."); } - if (channel === "matrix-js") { + if (channel === "matrix") { return stopWithText("⚠️ /unfocus must be run inside a focused Matrix thread."); } return stopWithText( @@ -82,7 +82,7 @@ export async function handleSubagentsUnfocusAction( return stopWithText( channel === "discord" ? "ℹ️ This thread is not currently focused." - : channel === "matrix-js" + : channel === "matrix" ? "ℹ️ This thread is not currently focused." : "ℹ️ This conversation is not currently focused.", ); @@ -95,7 +95,7 @@ export async function handleSubagentsUnfocusAction( return stopWithText( channel === "discord" ? `⚠️ Only ${boundBy} can unfocus this thread.` - : channel === "matrix-js" + : channel === "matrix" ? `⚠️ Only ${boundBy} can unfocus this thread.` : `⚠️ Only ${boundBy} can unfocus this conversation.`, ); @@ -106,7 +106,7 @@ export async function handleSubagentsUnfocusAction( reason: "manual", }); return stopWithText( - channel === "discord" || channel === "matrix-js" + channel === "discord" || channel === "matrix" ? "✅ Thread unfocused." : "✅ Conversation unfocused.", ); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index d592a56e475..a98c0a95aa4 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -235,6 +235,29 @@ const COMMON_SINGLE_ACCOUNT_KEYS_TO_MOVE = new Set([ ]); const SINGLE_ACCOUNT_KEYS_TO_MOVE_BY_CHANNEL: Record> = { + matrix: new Set([ + "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"]), }; diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index 890fb22d393..a47c8b3dad3 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -18,7 +18,7 @@ describe("resolveThreadBindingSpawnPolicy", () => { expect( resolveThreadBindingSpawnPolicy({ cfg: baseCfg, - channel: "matrix-js", + channel: "matrix", kind: "subagent", }).spawnEnabled, ).toBe(false); @@ -35,7 +35,7 @@ describe("resolveThreadBindingSpawnPolicy", () => { const cfg = { ...baseCfg, channels: { - "matrix-js": { + matrix: { threadBindings: { spawnSubagentSessions: true, spawnAcpSessions: true, @@ -47,14 +47,14 @@ describe("resolveThreadBindingSpawnPolicy", () => { expect( resolveThreadBindingSpawnPolicy({ cfg, - channel: "matrix-js", + channel: "matrix", kind: "subagent", }).spawnEnabled, ).toBe(true); expect( resolveThreadBindingSpawnPolicy({ cfg, - channel: "matrix-js", + channel: "matrix", kind: "acp", }).spawnEnabled, ).toBe(true); diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 3334ecaea03..518bbf6a14d 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; -export const MATRIX_JS_THREAD_BINDING_CHANNEL = "matrix-js"; +export const MATRIX_THREAD_BINDING_CHANNEL = "matrix"; export const TELEGRAM_THREAD_BINDING_CHANNEL = "telegram"; const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -116,7 +116,7 @@ function resolveSpawnConfigPath(params: { params.kind === "subagent" ? "spawnSubagentSessions=true" : "spawnAcpSessions=true"; if ( params.channel === DISCORD_THREAD_BINDING_CHANNEL || - params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL || + params.channel === MATRIX_THREAD_BINDING_CHANNEL || params.channel === TELEGRAM_THREAD_BINDING_CHANNEL ) { return `channels.${params.channel}.threadBindings.${suffix}`; @@ -215,8 +215,8 @@ export function formatThreadBindingSpawnDisabledError(params: { const label = params.channel === DISCORD_THREAD_BINDING_CHANNEL ? "Discord" - : params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL - ? "Matrix-js" + : params.channel === MATRIX_THREAD_BINDING_CHANNEL + ? "Matrix" : params.channel === TELEGRAM_THREAD_BINDING_CHANNEL ? "Telegram" : params.channel; diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 2d37e56a702..15fcc4d06dd 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -174,7 +174,7 @@ describe("registerAgentCommands", () => { "--agent", "ops", "--bind", - "matrix-js:ops", + "matrix:ops", "--bind", "telegram", "--json", @@ -182,7 +182,7 @@ describe("registerAgentCommands", () => { expect(agentsBindCommandMock).toHaveBeenCalledWith( { agent: "ops", - bind: ["matrix-js:ops", "telegram"], + bind: ["matrix:ops", "telegram"], json: true, }, runtime, diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index 0fe03173be6..bbe940e78d9 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => { return { ...actual, getChannelPlugin: (channel: string) => { - if (channel === "matrix-js") { + if (channel === "matrix") { return { - id: "matrix-js", + id: "matrix", setup: { resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(), }, @@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => { return actual.getChannelPlugin(channel); }, normalizeChannelId: (channel: string) => { - if (channel.trim().toLowerCase() === "matrix-js") { - return "matrix-js"; + if (channel.trim().toLowerCase() === "matrix") { + return "matrix"; } return actual.normalizeChannelId(channel); }, @@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => { ...baseConfigSnapshot, config: { bindings: [ - { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "main", match: { channel: "matrix" } }, { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, ], }, @@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => { await agentsBindingsCommand({}, runtime); - expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js")); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix")); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("ops <- telegram accountId=work"), ); @@ -76,23 +76,25 @@ describe("agents bind/unbind commands", () => { expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "telegram" } }], + bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }], }), ); expect(runtime.exit).not.toHaveBeenCalled(); }); - it("defaults matrix-js accountId to the target agent id when omitted", async () => { + it("defaults matrix accountId to the target agent id when omitted", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, config: {}, }); - await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime); + await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime); expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }], + bindings: [ + { type: "route", agentId: "main", match: { channel: "matrix", accountId: "main" } }, + ], }), ); expect(runtime.exit).not.toHaveBeenCalled(); @@ -123,7 +125,7 @@ describe("agents bind/unbind commands", () => { config: { agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] }, bindings: [ - { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "main", match: { channel: "matrix" } }, { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, ], }, @@ -133,7 +135,7 @@ describe("agents bind/unbind commands", () => { expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "matrix-js" } }], + bindings: [{ agentId: "main", match: { channel: "matrix" } }], }), ); expect(runtime.exit).not.toHaveBeenCalled(); diff --git a/src/commands/agents.bind.matrix-js.integration.test.ts b/src/commands/agents.bind.matrix.integration.test.ts similarity index 74% rename from src/commands/agents.bind.matrix-js.integration.test.ts rename to src/commands/agents.bind.matrix.integration.test.ts index 0c1ea91b6b0..416d9f88250 100644 --- a/src/commands/agents.bind.matrix-js.integration.test.ts +++ b/src/commands/agents.bind.matrix.integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js"; +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"; @@ -15,7 +15,7 @@ vi.mock("../config/config.js", async (importOriginal) => ({ writeConfigFile: writeConfigFileMock, })); -describe("agents bind matrix-js integration", () => { +describe("agents bind matrix integration", () => { const runtime = createTestRuntime(); beforeEach(() => { @@ -26,7 +26,7 @@ describe("agents bind matrix-js integration", () => { runtime.exit.mockClear(); setActivePluginRegistry( - createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]), + createTestRegistry([{ pluginId: "matrix", plugin: matrixPlugin, source: "test" }]), ); }); @@ -34,17 +34,19 @@ describe("agents bind matrix-js integration", () => { setDefaultChannelPluginRegistryForTests(); }); - it("uses matrix-js plugin binding resolver when accountId is omitted", async () => { + it("uses matrix plugin binding resolver when accountId is omitted", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, config: {}, }); - await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime); + await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime); expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }], + bindings: [ + { type: "route", agentId: "main", match: { channel: "matrix", accountId: "main" } }, + ], }), ); expect(runtime.exit).not.toHaveBeenCalled(); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 2ce46adeb29..f57997fe16e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -2,6 +2,7 @@ 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 { resolveMatrixAccountStorageRoot } from "../infra/matrix-storage-paths.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js"; @@ -145,6 +146,185 @@ describe("doctor config flow", () => { }); }); + it("previews Matrix legacy state migration in read-only mode", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + 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, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"next_batch":"s1"}', + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true }, + confirm: async () => false, + }); + }); + + const warning = noteSpy.mock.calls.find( + (call) => + call[1] === "Doctor warnings" && + String(call[0]).includes("Matrix plugin upgraded in place."), + ); + expect(warning?.[0]).toContain("Legacy sync store:"); + expect(warning?.[0]).toContain( + 'Run "openclaw doctor --fix" to migrate this Matrix state now.', + ); + } finally { + noteSpy.mockRestore(); + } + }); + + it("previews Matrix encrypted-state migration in read-only mode", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(accountRoot, "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICE123" }), + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true }, + confirm: async () => false, + }); + }); + + const warning = noteSpy.mock.calls.find( + (call) => + call[1] === "Doctor warnings" && + String(call[0]).includes("Matrix encrypted-state migration is pending"), + ); + expect(warning?.[0]).toContain("Legacy crypto store:"); + expect(warning?.[0]).toContain("New recovery key file:"); + } finally { + noteSpy.mockRestore(); + } + }); + + it("migrates Matrix legacy state on doctor repair", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + 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, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"next_batch":"s1"}', + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + + const migratedRoot = path.join( + stateDir, + "matrix", + "accounts", + "default", + "matrix.example.org__bot_example.org", + ); + const migratedChildren = await fs.readdir(migratedRoot); + expect(migratedChildren.length).toBe(1); + expect( + await fs + .access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json")) + .then(() => true) + .catch(() => false), + ).toBe(true); + expect( + await fs + .access(path.join(stateDir, "matrix", "bot-storage.json")) + .then(() => true) + .catch(() => false), + ).toBe(false); + }); + + expect( + noteSpy.mock.calls.some( + (call) => + call[1] === "Doctor changes" && + String(call[0]).includes("Matrix plugin upgraded in place."), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + } + }); + + it("warns when Matrix is installed from a stale custom path", async () => { + const doctorWarnings = await collectDoctorWarnings({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + }, + }, + plugins: { + installs: { + matrix: { + source: "path", + sourcePath: "/tmp/openclaw-matrix-missing", + installPath: "/tmp/openclaw-matrix-missing", + }, + }, + }, + }); + + expect( + doctorWarnings.some((line) => + line.includes("Matrix is installed from a custom path that no longer exists"), + ), + ).toBe(true); + expect( + doctorWarnings.some((line) => line.includes("openclaw plugins install @openclaw/matrix")), + ).toBe(true); + }); + it("preserves discord streaming intent while stripping unsupported keys on repair", async () => { const result = await runDoctorConfigWithInput({ repair: true, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ff97c001f07..177182e7328 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -26,6 +26,14 @@ import { isTrustedSafeBinPath, normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; +import { + autoPrepareLegacyMatrixCrypto, + detectLegacyMatrixCrypto, +} from "../infra/matrix-legacy-crypto.js"; +import { + autoMigrateLegacyMatrixState, + detectLegacyMatrixState, +} from "../infra/matrix-legacy-state.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { formatChannelAccountsDefaultPath, @@ -285,6 +293,68 @@ function collectTelegramAllowFromLists( return refs; } +function formatMatrixLegacyStatePreview( + detection: Exclude, null | { warning: string }>, +): string { + return [ + "- Matrix plugin upgraded in place.", + `- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`, + `- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`, + '- Run "openclaw doctor --fix" to migrate this Matrix state now.', + ].join("\n"); +} + +function formatMatrixLegacyCryptoPreview( + detection: ReturnType, +): string[] { + const notes: string[] = []; + for (const warning of detection.warnings) { + notes.push(`- ${warning}`); + } + for (const plan of detection.plans) { + notes.push( + [ + `- Matrix encrypted-state migration is pending for account "${plan.accountId}".`, + `- Legacy crypto store: ${plan.legacyCryptoPath}`, + `- New recovery key file: ${plan.recoveryKeyPath}`, + `- Migration state file: ${plan.statePath}`, + '- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.', + ].join("\n"), + ); + } + return notes; +} + +async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise { + const install = cfg.plugins?.installs?.matrix; + if (!install || install.source !== "path") { + return []; + } + + const candidatePaths = [install.sourcePath, install.installPath] + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean); + if (candidatePaths.length === 0) { + return []; + } + + for (const candidatePath of candidatePaths) { + try { + await fs.access(path.resolve(candidatePath)); + return []; + } catch { + // keep checking remaining candidates + } + } + + const missingPath = candidatePaths[0] ?? "(unknown)"; + return [ + `- Matrix is installed from a custom path that no longer exists: ${missingPath}`, + `- Reinstall with "${formatCliCommand("openclaw plugins install @openclaw/matrix")}".`, + `- If you are running from a repo checkout, you can also use "${formatCliCommand("openclaw plugins install ./extensions/matrix")}".`, + ]; +} + function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllowFromUsernameHit[] { const hits: TelegramAllowFromUsernameHit[] = []; @@ -1733,6 +1803,69 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } } + const matrixLegacyState = detectLegacyMatrixState({ + cfg: candidate, + env: process.env, + }); + const matrixLegacyCrypto = detectLegacyMatrixCrypto({ + cfg: candidate, + env: process.env, + }); + if (shouldRepair) { + const matrixStateRepair = await autoMigrateLegacyMatrixState({ + cfg: candidate, + env: process.env, + }); + if (matrixStateRepair.changes.length > 0) { + note( + [ + "Matrix plugin upgraded in place.", + ...matrixStateRepair.changes.map((entry) => `- ${entry}`), + "- No user action required.", + ].join("\n"), + "Doctor changes", + ); + } + if (matrixStateRepair.warnings.length > 0) { + note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings"); + } + const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({ + cfg: candidate, + env: process.env, + }); + if (matrixCryptoRepair.changes.length > 0) { + note( + [ + "Matrix encrypted-state migration prepared.", + ...matrixCryptoRepair.changes.map((entry) => `- ${entry}`), + ].join("\n"), + "Doctor changes", + ); + } + if (matrixCryptoRepair.warnings.length > 0) { + note(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings"); + } + } else if (matrixLegacyState) { + if ("warning" in matrixLegacyState) { + note(`- ${matrixLegacyState.warning}`, "Doctor warnings"); + } else { + note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings"); + } + } + if ( + !shouldRepair && + (matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0) + ) { + for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) { + note(preview, "Doctor warnings"); + } + } + + const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate); + if (matrixInstallWarnings.length > 0) { + note(matrixInstallWarnings.join("\n"), "Doctor warnings"); + } + const missingDefaultAccountBindingWarnings = collectMissingDefaultAccountBindingWarnings(candidate); if (missingDefaultAccountBindingWarnings.length > 0) { diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index 4910c7f9488..d77001a5a0b 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -96,6 +96,25 @@ describe("legacy migrate mention routing", () => { }); }); +describe("legacy migrate Matrix config", () => { + it("removes the obsolete channels.matrix.register toggle", () => { + const res = migrateLegacyConfig({ + channels: { + matrix: { + register: false, + homeserver: "https://matrix.example.org", + }, + }, + }); + + expect(res.changes).toContain("Removed obsolete channels.matrix.register."); + expect( + (res.config?.channels?.matrix as { register?: unknown } | undefined)?.register, + ).toBeUndefined(); + expect(res.config?.channels?.matrix?.homeserver).toBe("https://matrix.example.org"); + }); +}); + describe("legacy migrate heartbeat config", () => { it("moves top-level heartbeat into agents.defaults.heartbeat", () => { const res = migrateLegacyConfig({ diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index ccc07b4b99f..2539b3f198f 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -97,6 +97,19 @@ function mergeLegacyIntoDefaults(params: { // tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod). export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ + { + id: "channels.matrix.register-remove", + describe: "Remove obsolete Matrix registration toggle", + apply: (raw, changes) => { + const channels = getRecord(raw.channels); + const matrix = getRecord(channels?.matrix); + if (!matrix || !("register" in matrix)) { + return; + } + delete matrix.register; + changes.push("Removed obsolete channels.matrix.register."); + }, + }, { // v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the // host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d711ee7c382..3989851e42c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -449,7 +449,7 @@ export const FIELD_HELP: Record = { "bindings[].match": "Match rule object for deciding when a binding applies, including channel and optional account/peer constraints. Keep rules narrow to avoid accidental agent takeover across contexts.", "bindings[].match.channel": - "Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix-js`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.", + "Channel/provider identifier this binding applies to, such as `telegram`, `discord`, `matrix`, or another plugin channel ID. Use the configured channel key exactly so binding evaluation works reliably.", "bindings[].match.accountId": "Optional account selector for multi-account channel setups so the binding applies only to one identity. Use this when account scoping is required for the route and leave unset otherwise.", "bindings[].match.peer": @@ -1543,15 +1543,15 @@ export const FIELD_HELP: Record = { "Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.", "channels.discord.threadBindings.spawnAcpSessions": "Allow /acp spawn to auto-create and bind Discord threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", - "channels.matrix-js.threadBindings.enabled": - "Enable Matrix-js thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.", - "channels.matrix-js.threadBindings.idleHours": - "Inactivity window in hours for Matrix-js thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", - "channels.matrix-js.threadBindings.maxAgeHours": - "Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", - "channels.matrix-js.threadBindings.spawnSubagentSessions": + "channels.matrix.threadBindings.enabled": + "Enable Matrix thread binding features (/focus, /unfocus, /agents, /session idle|max-age, and thread-bound routing). Overrides session.threadBindings.enabled when set.", + "channels.matrix.threadBindings.idleHours": + "Inactivity window in hours for Matrix thread-bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.", + "channels.matrix.threadBindings.maxAgeHours": + "Optional hard max age in hours for Matrix thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", + "channels.matrix.threadBindings.spawnSubagentSessions": "Allow top-level /focus flows to auto-create and bind Matrix threads for subagent/session targets (default: false; opt-in). Set true to enable Matrix thread creation/binding from room or DM contexts.", - "channels.matrix-js.threadBindings.spawnAcpSessions": + "channels.matrix.threadBindings.spawnAcpSessions": "Allow /acp spawn to create or bind Matrix threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", "channels.discord.ui.components.accentColor": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index bf6446a9da4..d4c9c946906 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -771,12 +771,11 @@ export const FIELD_LABELS: Record = { "channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)", "channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn", "channels.discord.threadBindings.spawnAcpSessions": "Discord Thread-Bound ACP Spawn", - "channels.matrix-js.threadBindings.enabled": "Matrix-js Thread Binding Enabled", - "channels.matrix-js.threadBindings.idleHours": "Matrix-js Thread Binding Idle Timeout (hours)", - "channels.matrix-js.threadBindings.maxAgeHours": "Matrix-js Thread Binding Max Age (hours)", - "channels.matrix-js.threadBindings.spawnSubagentSessions": - "Matrix-js Thread-Bound Subagent Spawn", - "channels.matrix-js.threadBindings.spawnAcpSessions": "Matrix-js Thread-Bound ACP Spawn", + "channels.matrix.threadBindings.enabled": "Matrix Thread Binding Enabled", + "channels.matrix.threadBindings.idleHours": "Matrix Thread Binding Idle Timeout (hours)", + "channels.matrix.threadBindings.maxAgeHours": "Matrix Thread Binding Max Age (hours)", + "channels.matrix.threadBindings.spawnSubagentSessions": "Matrix Thread-Bound Subagent Spawn", + "channels.matrix.threadBindings.spawnAcpSessions": "Matrix Thread-Bound ACP Spawn", "channels.discord.ui.components.accentColor": "Discord Component Accent Color", "channels.discord.intents.presence": "Discord Presence Intent", "channels.discord.intents.guildMembers": "Discord Guild Members Intent", diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 03336561d64..841d71ed8c8 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -100,6 +100,16 @@ export type SessionThreadBindingsConfig = { * Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0. */ maxAgeHours?: number; + /** + * Allow thread-capable channels to create and bind child conversations for subagent sessions. + * Channels that support this use explicit opt-in. Default: false. + */ + spawnSubagentSessions?: boolean; + /** + * Allow thread-capable channels to create and bind child conversations for ACP sessions. + * Channels that support this use explicit opt-in. Default: false. + */ + spawnAcpSessions?: boolean; }; export type SessionConfig = { diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 0b9394111d8..5eed09f4354 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -71,12 +71,12 @@ const AcpBindingSchema = z return; } const channel = value.match.channel.trim().toLowerCase(); - if (channel !== "discord" && channel !== "matrix-js" && channel !== "telegram") { + if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["match", "channel"], message: - 'ACP bindings currently support only "discord", "matrix-js", and "telegram" channels.', + 'ACP bindings currently support only "discord", "matrix", and "telegram" channels.', }); return; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 898cdc6fe87..4a6bc1cd365 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -34,6 +34,8 @@ import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; +import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js"; +import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { @@ -331,6 +333,16 @@ export async function startGatewayServer( } let secretsDegraded = false; + await autoMigrateLegacyMatrixState({ + cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config, + env: process.env, + log, + }); + await autoPrepareLegacyMatrixCrypto({ + cfg: autoEnable.changes.length > 0 ? autoEnable.config : configSnapshot.config, + env: process.env, + log, + }); const emitSecretsStateEvent = ( code: "SECRETS_RELOADER_DEGRADED" | "SECRETS_RELOADER_RECOVERED", message: string, diff --git a/src/infra/matrix-legacy-crypto.test.ts b/src/infra/matrix-legacy-crypto.test.ts new file mode 100644 index 00000000000..bbaa20510f2 --- /dev/null +++ b/src/infra/matrix-legacy-crypto.test.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./matrix-legacy-crypto.js"; +import { resolveMatrixAccountStorageRoot } from "./matrix-storage-paths.js"; + +function writeFile(filePath: string, value: string) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, value, "utf8"); +} + +describe("matrix legacy encrypted-state migration", () => { + it("extracts a saved backup key into the new recovery-key path", 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 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); + }); + }); + + 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"); + }); + }); +}); diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts new file mode 100644 index 00000000000..7f401b609d6 --- /dev/null +++ b/src/infra/matrix-legacy-crypto.ts @@ -0,0 +1,576 @@ +import crypto from "node:crypto"; +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 { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; +import { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "./matrix-storage-paths.js"; + +type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; +}; + +type MatrixLegacyCryptoCounts = { + total: number; + backedUp: number; +}; + +type MatrixLegacyCryptoSummary = { + deviceId: string | null; + roomKeyCounts: MatrixLegacyCryptoCounts | null; + backupVersion: string | null; + decryptionKeyBase64: string | null; +}; + +export 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[]; +}; + +export type MatrixLegacyCryptoPreparationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +export type MatrixLegacyCryptoPrepareDeps = { + inspectLegacyStore: (params: { + cryptoRootDir: string; + userId: string; + deviceId: string; + }) => Promise; +}; + +type MatrixLegacyBotSdkMetadata = { + deviceId: string | null; +}; + +type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean { + return ( + 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")), + ) + ); +} + +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; + } +} + +function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return []; + } + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + if (!accounts) { + return [DEFAULT_ACCOUNT_ID]; + } + const ids = Object.keys(accounts).map((accountId) => normalizeAccountId(accountId)); + return Array.from(new Set(ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID])).toSorted((a, b) => + a.localeCompare(b), + ); +} + +function resolveMatrixAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): Record { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return {}; + } + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null; + const merged = { + ...channel, + ...accountEntry, + }; + delete merged.accounts; + return merged; +} + +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) || !isLegacyBotSdkCryptoStore(legacy.cryptoPath)) { + return null; + } + + const channel = resolveMatrixChannelConfig(params.cfg); + if (!channel) { + return { + warning: + `Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but channels.matrix is not configured yet. ` + + 'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.', + }; + } + + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + const configuredDefault = normalizeOptionalAccountId( + typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, + ); + const accountId = + configuredDefault && accounts && isRecord(accounts[configuredDefault]) + ? configuredDefault + : DEFAULT_ACCOUNT_ID; + const stored = loadStoredMatrixCredentials(params.env, accountId); + const account = resolveMatrixAccountConfig(params.cfg, accountId); + const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : ""; + const userId = + (typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || ""; + const accessToken = + (typeof account.accessToken === "string" ? account.accessToken.trim() : "") || + stored?.accessToken || + ""; + + if (!homeserver || !userId || !accessToken) { + return { + warning: + `Legacy Matrix encrypted state detected at ${legacy.cryptoPath}, but the account-scoped target 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.', + }; + } + + const stateDir = resolveStateDir(params.env, os.homedir); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver, + userId, + accessToken, + accountId, + }); + const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath); + return { + accountId, + rootDir, + recoveryKeyPath: path.join(rootDir, "recovery-key.json"), + statePath: path.join(rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath: legacy.cryptoPath, + homeserver, + userId, + accessToken, + deviceId: metadata.deviceId ?? stored?.deviceId ?? null, + }; +} + +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); + } + } + + const stateDir = resolveStateDir(params.env, os.homedir); + for (const accountId of resolveMatrixAccountIds(params.cfg)) { + const account = resolveMatrixAccountConfig(params.cfg, accountId); + const stored = loadStoredMatrixCredentials(params.env, accountId); + const homeserver = + (typeof account.homeserver === "string" ? account.homeserver.trim() : "") || + stored?.homeserver || + ""; + const userId = + (typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || ""; + const accessToken = + (typeof account.accessToken === "string" ? account.accessToken.trim() : "") || + stored?.accessToken || + ""; + if (!homeserver || !userId || !accessToken) { + continue; + } + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver, + userId, + accessToken, + accountId, + }); + const legacyCryptoPath = path.join(rootDir, "crypto"); + if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) { + continue; + } + if ( + plans.some( + (plan) => + plan.accountId === accountId && + path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath), + ) + ) { + continue; + } + const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath); + plans.push({ + accountId, + rootDir, + recoveryKeyPath: path.join(rootDir, "recovery-key.json"), + statePath: path.join(rootDir, "legacy-crypto-migration.json"), + legacyCryptoPath, + homeserver, + userId, + accessToken, + deviceId: metadata.deviceId ?? stored?.deviceId ?? null, + }); + } + + 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; + } +} + +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; +} + +async function inspectLegacyStoreWithCryptoNodejs(params: { + cryptoRootDir: string; + userId: string; + deviceId: string; +}): Promise { + const machineStorePath = resolveLegacyMachineStorePath(params); + if (!machineStorePath) { + throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`); + } + const { DeviceId, OlmMachine, StoreType, UserId } = + await 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(); + } +} + +async function persistLegacyMigrationState(params: { + filePath: string; + state: MatrixLegacyCryptoMigrationState; +}): Promise { + await writeJsonFileAtomically(params.filePath, params.state); +} + +export function detectLegacyMatrixCrypto(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MatrixLegacyCryptoDetection { + return resolveMatrixLegacyCryptoPlans({ + cfg: params.cfg, + env: params.env ?? process.env, + }); +} + +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 = resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env }); + const warnings = [...detection.warnings]; + const changes: string[] = []; + const inspectLegacyStore = params.deps?.inspectLegacyStore ?? inspectLegacyStoreWithCryptoNodejs; + + 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, + }); + } 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, + }; + await writeJsonFileAtomically(plan.recoveryKeyPath, payload); + changes.push( + `Imported Matrix legacy backup key for account "${plan.accountId}": ${plan.recoveryKeyPath}`, + ); + decryptionKeyImported = true; + } 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.`, + ); + } + + 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, + }; + await persistLegacyMigrationState({ filePath: plan.statePath, state }); + changes.push( + `Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}": ${plan.statePath}`, + ); + } + + 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..4f85d82ad41 --- /dev/null +++ b/src/infra/matrix-legacy-state.test.ts @@ -0,0 +1,86 @@ +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", + }, + }, + }; + + 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); + }); + }); +}); diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts new file mode 100644 index 00000000000..9cde9bf05dd --- /dev/null +++ b/src/infra/matrix-legacy-state.ts @@ -0,0 +1,286 @@ +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 { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; +import { + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, + resolveMatrixLegacyFlatStoragePaths, +} from "./matrix-storage-paths.js"; + +type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; +}; + +export type MatrixLegacyStateMigrationResult = { + migrated: boolean; + changes: string[]; + warnings: string[]; +}; + +type MatrixLegacyStatePlan = { + accountId: string; + legacyStoragePath: string; + legacyCryptoPath: string; + targetRootDir: string; + targetStoragePath: string; + targetCryptoPath: string; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): { + rootDir: string; + storagePath: string; + cryptoPath: string; +} { + const stateDir = resolveStateDir(env, os.homedir); + return resolveMatrixLegacyFlatStoragePaths(stateDir); +} + +function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv, accountId: string): string { + const stateDir = resolveStateDir(env, os.homedir); + return resolveSharedMatrixCredentialsPath({ + stateDir, + accountId: normalizeAccountId(accountId), + }); +} + +function loadStoredMatrixCredentials( + env: NodeJS.ProcessEnv, + accountId: string, +): MatrixStoredCredentials | null { + const credentialsPath = resolveMatrixCredentialsPath(env, accountId); + try { + if (!fs.existsSync(credentialsPath)) { + return null; + } + const parsed = JSON.parse( + fs.readFileSync(credentialsPath, "utf-8"), + ) 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, + }; + } catch { + return null; + } +} + +function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +function resolveMatrixAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): Record { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return {}; + } + + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null; + + const merged = { + ...channel, + ...accountEntry, + }; + delete merged.accounts; + return merged; +} + +function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string { + const channel = resolveMatrixChannelConfig(cfg); + if (!channel) { + return DEFAULT_ACCOUNT_ID; + } + + const accounts = isRecord(channel.accounts) ? channel.accounts : null; + const configuredDefault = normalizeOptionalAccountId( + typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined, + ); + if (configuredDefault && accounts && isRecord(accounts[configuredDefault])) { + return configuredDefault; + } + if (accounts && isRecord(accounts[DEFAULT_ACCOUNT_ID])) { + return DEFAULT_ACCOUNT_ID; + } + return DEFAULT_ACCOUNT_ID; +} + +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 channel = resolveMatrixChannelConfig(params.cfg); + if (!channel) { + return { + warning: + `Legacy Matrix state detected at ${legacy.rootDir}, but channels.matrix is not configured yet. ` + + 'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.', + }; + } + + const accountId = resolveMatrixTargetAccountId(params.cfg); + const account = resolveMatrixAccountConfig(params.cfg, accountId); + const stored = loadStoredMatrixCredentials(params.env, accountId); + + const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : ""; + const configUserId = typeof account.userId === "string" ? account.userId.trim() : ""; + const configAccessToken = + typeof account.accessToken === "string" ? account.accessToken.trim() : ""; + + const storedMatchesHomeserver = + stored && homeserver ? stored.homeserver === homeserver : Boolean(stored); + const storedMatchesUser = + stored && configUserId ? stored.userId === configUserId : Boolean(stored); + + const userId = + configUserId || (storedMatchesHomeserver && storedMatchesUser ? (stored?.userId ?? "") : ""); + const accessToken = + configAccessToken || + (storedMatchesHomeserver && storedMatchesUser ? (stored?.accessToken ?? "") : ""); + + if (!homeserver || !userId || !accessToken) { + return { + warning: + `Legacy Matrix state detected at ${legacy.rootDir}, but the new account-scoped target 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.', + }; + } + + const stateDir = resolveStateDir(params.env, os.homedir); + const { rootDir } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver, + userId, + accessToken, + accountId, + }); + + return { + accountId, + legacyStoragePath: legacy.storagePath, + legacyCryptoPath: legacy.cryptoPath, + targetRootDir: rootDir, + targetStoragePath: path.join(rootDir, "bot-storage.json"), + targetCryptoPath: path.join(rootDir, "crypto"), + }; +} + +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) { + params.log?.info?.( + `matrix: plugin upgraded in place for account "${detection.accountId}".\n${changes + .map((entry) => `- ${entry}`) + .join("\n")}\n- No user action required.`, + ); + } + 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-storage-paths.ts b/src/infra/matrix-storage-paths.ts new file mode 100644 index 00000000000..08ab39c16a5 --- /dev/null +++ b/src/infra/matrix-storage-paths.ts @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; +import path from "node:path"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; + +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/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index 14aba71a07d..e8639029e0f 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { matrixPlugin } from "../../../extensions/matrix-js/src/channel.js"; +import { matrixPlugin } from "../../../extensions/matrix/src/channel.js"; import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import { telegramPlugin } from "../../../extensions/telegram/src/channel.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -52,7 +52,7 @@ const telegramConfig = { const matrixConfig = { channels: { - "matrix-js": { + matrix: { homeserver: "https://matrix.example.org", accessToken: "matrix-test", }, @@ -102,14 +102,14 @@ const defaultMatrixDmToolContext = { } as const; let createPluginRuntime: typeof import("../../plugins/runtime/index.js").createPluginRuntime; -let setMatrixRuntime: typeof import("../../../extensions/matrix-js/src/runtime.js").setMatrixRuntime; +let setMatrixRuntime: typeof import("../../../extensions/matrix/src/runtime.js").setMatrixRuntime; let setSlackRuntime: typeof import("../../../extensions/slack/src/runtime.js").setSlackRuntime; let setTelegramRuntime: typeof import("../../../extensions/telegram/src/runtime.js").setTelegramRuntime; describe("runMessageAction threading auto-injection", () => { beforeAll(async () => { ({ createPluginRuntime } = await import("../../plugins/runtime/index.js")); - ({ setMatrixRuntime } = await import("../../../extensions/matrix-js/src/runtime.js")); + ({ setMatrixRuntime } = await import("../../../extensions/matrix/src/runtime.js")); ({ setSlackRuntime } = await import("../../../extensions/slack/src/runtime.js")); ({ setTelegramRuntime } = await import("../../../extensions/telegram/src/runtime.js")); }); @@ -122,7 +122,7 @@ describe("runMessageAction threading auto-injection", () => { setActivePluginRegistry( createTestRegistry([ { - pluginId: "matrix-js", + pluginId: "matrix", source: "test", plugin: matrixPlugin, }, @@ -278,7 +278,7 @@ describe("runMessageAction threading auto-injection", () => { const call = await runThreadingAction({ cfg: matrixConfig, actionParams: { - channel: "matrix-js", + channel: "matrix", target: testCase.target, message: "hi", }, @@ -297,7 +297,7 @@ describe("runMessageAction threading auto-injection", () => { const call = await runThreadingAction({ cfg: matrixConfig, actionParams: { - channel: "matrix-js", + channel: "matrix", target: "room:!room:example.org", message: "hi", threadId: "$explicit", @@ -315,7 +315,7 @@ describe("runMessageAction threading auto-injection", () => { const call = await runThreadingAction({ cfg: matrixConfig, actionParams: { - channel: "matrix-js", + channel: "matrix", target: "user:@alice:example.org", message: "hi", }, @@ -332,7 +332,7 @@ describe("runMessageAction threading auto-injection", () => { const call = await runThreadingAction({ cfg: matrixConfig, actionParams: { - channel: "matrix-js", + channel: "matrix", target: "user:@bob:example.org", message: "hi", }, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 418143493f1..5b33d4e4ed3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -80,7 +80,7 @@ function resolveAndApplyOutboundThreadId( ? resolveTelegramAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext }) : undefined; const matrixAutoThreadId = - ctx.channel === "matrix-js" && !threadId + ctx.channel === "matrix" && !threadId ? resolveMatrixAutoThreadId({ to: ctx.to, toolContext: ctx.toolContext }) : undefined; const resolved = threadId ?? slackAutoThreadId ?? telegramAutoThreadId ?? matrixAutoThreadId; diff --git a/src/plugin-sdk/matrix-js.ts b/src/plugin-sdk/matrix-js.ts deleted file mode 100644 index 76aadf2c99c..00000000000 --- a/src/plugin-sdk/matrix-js.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Narrow plugin-sdk surface for the bundled matrix-js plugin. -// Keep this list additive and scoped to symbols used under extensions/matrix-js. - -export { - createActionGate, - jsonResult, - readNumberParam, - readReactionParams, - readStringArrayParam, - readStringParam, -} from "../agents/tools/common.js"; -export type { ReplyPayload } from "../auto-reply/types.js"; -export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; -export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; -export { resolveControlCommandGate } from "../channels/command-gating.js"; -export type { NormalizedLocation } from "../channels/location.js"; -export { formatLocationText, toLocationContext } from "../channels/location.js"; -export { logInboundDrop, logTypingFailure } from "../channels/logging.js"; -export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; -export { formatAllowlistMatchMeta } from "../channels/plugins/allowlist-match.js"; -export { - buildChannelKeyCandidates, - resolveChannelEntryMatch, -} from "../channels/plugins/channel-config.js"; -export { - deleteAccountFromConfigSection, - setAccountEnabledInConfigSection, -} from "../channels/plugins/config-helpers.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; -export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; -export { - addWildcardAllowFrom, - mergeAllowFromEntries, - promptSingleChannelSecretInput, -} from "../channels/plugins/onboarding/helpers.js"; -export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; -export { migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js"; -export type { - BaseProbeResult, - ChannelDirectoryEntry, - ChannelGroupContext, - ChannelMessageActionAdapter, - ChannelMessageActionContext, - ChannelMessageActionName, - ChannelOutboundAdapter, - ChannelResolveKind, - ChannelResolveResult, - ChannelToolSend, -} from "../channels/plugins/types.js"; -export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { ChannelSetupInput } from "../channels/plugins/types.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 { resolveAckReaction } from "../agents/identity.js"; -export type { OpenClawConfig } from "../config/config.js"; -export { - GROUP_POLICY_BLOCKED_LABEL, - resolveAllowlistProviderRuntimeGroupPolicy, - resolveDefaultGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "../config/runtime-group-policy.js"; -export type { - DmPolicy, - GroupPolicy, - GroupToolPolicyConfig, - MarkdownTableMode, -} from "../config/types.js"; -export type { SecretInput } from "../config/types.secrets.js"; -export { - hasConfiguredSecretInput, - normalizeResolvedSecretInputString, - normalizeSecretInputString, -} from "../config/types.secrets.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 { - getSessionBindingService, - registerSessionBindingAdapter, - unregisterSessionBindingAdapter, -} from "../infra/outbound/session-binding-service.js"; -export type { - BindingTargetKind, - SessionBindingRecord, - SessionBindingAdapter, -} from "../infra/outbound/session-binding-service.js"; -export { issuePairingChallenge } from "../pairing/pairing-challenge.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 { normalizePollInput } from "../polls.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -export { resolveConfiguredAcpRoute } from "../acp/persistent-bindings.route.js"; -export { ensureConfiguredAcpRouteReady } from "../acp/persistent-bindings.route.js"; -export type { RuntimeEnv } from "../runtime.js"; -export { - readStoreAllowFromForDmPolicy, - resolveDmGroupAccessWithLists, -} from "../security/dm-policy-shared.js"; -export { formatDocsLink } from "../terminal/links.js"; -export type { WizardPrompter } from "../wizard/prompts.js"; -export { createScopedPairingAccess } from "./pairing-access.js"; -export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; -export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; -export { runPluginCommandWithTimeout } from "./run-command.js"; -export { createLoggerBackedRuntime } from "./runtime.js"; -export { buildProbeChannelStatusSummary } from "./status-helpers.js"; -export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; -export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index d0997ee3b98..54b54f97823 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -10,12 +10,20 @@ export { readStringParam, } from "../agents/tools/common.js"; export type { ReplyPayload } from "../auto-reply/types.js"; +export { resolveAckReaction } from "../agents/identity.js"; export { - compileAllowlist, - resolveAllowlistCandidates, - resolveAllowlistMatchByCandidates, -} from "../channels/allowlist-match.js"; -export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; + resolveConfiguredAcpRoute, + ensureConfiguredAcpRouteReady, +} from "../acp/persistent-bindings.route.js"; +export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js"; +export { + addAllowlistUserEntriesFromConfigEntry, + buildAllowlistResolutionSummary, + canonicalizeAllowlistWithResolvedIds, + mergeAllowlist, + patchAllowlistUsersInConfigEntries, + summarizeMapping, +} from "../channels/allowlists/resolve-utils.js"; export { resolveControlCommandGate } from "../channels/command-gating.js"; export type { NormalizedLocation } from "../channels/location.js"; export { formatLocationText, toLocationContext } from "../channels/location.js"; @@ -41,11 +49,16 @@ export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, mergeAllowFromEntries, + promptAccountId, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; -export { applyAccountNameToChannelSection } from "../channels/plugins/setup-helpers.js"; +export { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, + moveSingleAccountChannelSectionToDefaultAccount, +} from "../channels/plugins/setup-helpers.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export type { BaseProbeResult, @@ -57,10 +70,17 @@ export type { 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 { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.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 type { OpenClawConfig } from "../config/config.js"; export { @@ -84,33 +104,66 @@ export { export { buildSecretInputSchema } from "./secret-input-schema.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 { + hashMatrixAccessToken, + resolveMatrixAccountStorageRoot, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsFilename, + resolveMatrixCredentialsPath, + resolveMatrixHomeserverKey, + resolveMatrixLegacyFlatStoragePaths, + sanitizeMatrixPathSegment, +} from "../infra/matrix-storage-paths.js"; +export { + hasActionableMatrixMigration, + hasPendingMatrixMigration, + maybeCreateMatrixMigrationSnapshot, + resolveMatrixMigrationSnapshotMarkerPath, + resolveMatrixMigrationSnapshotOutputDir, +} from "../infra/matrix-migration-snapshot.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { + getSessionBindingService, + registerSessionBindingAdapter, + unregisterSessionBindingAdapter, +} from "../infra/outbound/session-binding-service.js"; +export type { + BindingTargetKind, + SessionBindingAdapter, + SessionBindingRecord, +} from "../infra/outbound/session-binding-service.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.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 { normalizePollInput } from "../polls.js"; -export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../routing/session-key.js"; export type { RuntimeEnv } from "../runtime.js"; export { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, } from "../security/dm-policy-shared.js"; -export { formatDocsLink } from "../terminal/links.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, } from "./group-access.js"; +export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js"; +export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.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 { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; +export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index fc4e0711c9d..ccdcd1eeb5e 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -26,7 +26,6 @@ const bundledExtensionSubpathLoaders = [ { id: "llm-task", load: () => import("openclaw/plugin-sdk/llm-task") }, { id: "lobster", load: () => import("openclaw/plugin-sdk/lobster") }, { id: "matrix", load: () => import("openclaw/plugin-sdk/matrix") }, - { id: "matrix-js", load: () => import("openclaw/plugin-sdk/matrix-js") }, { id: "mattermost", load: () => import("openclaw/plugin-sdk/mattermost") }, { id: "memory-core", load: () => import("openclaw/plugin-sdk/memory-core") }, { id: "memory-lancedb", load: () => import("openclaw/plugin-sdk/memory-lancedb") }, diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index ae0bb67b001..7e2b76d745e 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -37,7 +37,6 @@ "src/plugin-sdk/llm-task.ts", "src/plugin-sdk/lobster.ts", "src/plugin-sdk/matrix.ts", - "src/plugin-sdk/matrix-js.ts", "src/plugin-sdk/mattermost.ts", "src/plugin-sdk/memory-core.ts", "src/plugin-sdk/memory-lancedb.ts", diff --git a/tsdown.config.ts b/tsdown.config.ts index 6f2c74bbbdc..80833de2a14 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -65,7 +65,6 @@ const pluginSdkEntrypoints = [ "llm-task", "lobster", "matrix", - "matrix-js", "mattermost", "memory-core", "memory-lancedb", diff --git a/vitest.config.ts b/vitest.config.ts index 26bcf574955..658437187f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,7 +33,6 @@ const pluginSdkSubpaths = [ "llm-task", "lobster", "matrix", - "matrix-js", "mattermost", "memory-core", "memory-lancedb",