From 5fddbc1d9b7d9cfe52433bb43d00ba4de1304894 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 4 Mar 2026 10:17:22 -0500 Subject: [PATCH] Matrix-js: sync with main plugin-loading standards --- docs/channels/matrix-js.md | 305 ++++ docs/tools/plugin.md | 1 + .../LEGACY_MATRIX_PARITY_GAP_AUDIT.md | 120 ++ .../matrix-js/LEGACY_MATRIX_PARITY_SPEC.md | 397 +++++ extensions/matrix-js/index.ts | 98 ++ extensions/matrix-js/openclaw.plugin.json | 9 + extensions/matrix-js/package.json | 37 + .../matrix-js/scripts/live-basic-send.ts | 104 ++ extensions/matrix-js/scripts/live-common.ts | 145 ++ .../scripts/live-cross-signing-probe.ts | 126 ++ .../matrix-js/scripts/live-e2ee-bootstrap.ts | 28 + .../matrix-js/scripts/live-e2ee-room-state.ts | 65 + .../matrix-js/scripts/live-e2ee-send-room.ts | 99 ++ .../matrix-js/scripts/live-e2ee-send.ts | 169 +++ .../matrix-js/scripts/live-e2ee-status.ts | 57 + .../matrix-js/scripts/live-e2ee-wait-reply.ts | 122 ++ .../matrix-js/scripts/live-read-room.ts | 56 + extensions/matrix-js/src/actions.ts | 242 ++++ .../matrix-js/src/channel.directory.test.ts | 397 +++++ extensions/matrix-js/src/channel.ts | 490 +++++++ extensions/matrix-js/src/cli.test.ts | 527 +++++++ extensions/matrix-js/src/cli.ts | 871 +++++++++++ extensions/matrix-js/src/config-migration.ts | 133 ++ extensions/matrix-js/src/config-schema.ts | 66 + .../matrix-js/src/directory-live.test.ts | 74 + extensions/matrix-js/src/directory-live.ts | 208 +++ extensions/matrix-js/src/group-mentions.ts | 52 + .../matrix-js/src/matrix/account-config.ts | 37 + .../matrix-js/src/matrix/accounts.test.ts | 82 ++ extensions/matrix-js/src/matrix/accounts.ts | 127 ++ extensions/matrix-js/src/matrix/actions.ts | 35 + .../src/matrix/actions/client.test.ts | 87 ++ .../matrix-js/src/matrix/actions/client.ts | 70 + .../src/matrix/actions/limits.test.ts | 15 + .../matrix-js/src/matrix/actions/limits.ts | 6 + .../matrix-js/src/matrix/actions/messages.ts | 111 ++ .../matrix-js/src/matrix/actions/pins.test.ts | 74 + .../matrix-js/src/matrix/actions/pins.ts | 79 + .../matrix-js/src/matrix/actions/profile.ts | 29 + .../src/matrix/actions/reactions.test.ts | 109 ++ .../matrix-js/src/matrix/actions/reactions.ts | 86 ++ .../matrix-js/src/matrix/actions/room.ts | 72 + .../matrix-js/src/matrix/actions/summary.ts | 75 + .../matrix-js/src/matrix/actions/types.ts | 75 + .../src/matrix/actions/verification.ts | 285 ++++ .../matrix-js/src/matrix/active-client.ts | 26 + .../matrix-js/src/matrix/client-bootstrap.ts | 39 + .../matrix-js/src/matrix/client.test.ts | 314 ++++ extensions/matrix-js/src/matrix/client.ts | 17 + .../matrix-js/src/matrix/client/config.ts | 362 +++++ .../src/matrix/client/create-client.ts | 58 + .../matrix-js/src/matrix/client/logging.ts | 100 ++ .../matrix-js/src/matrix/client/runtime.ts | 4 + .../src/matrix/client/shared.test.ts | 112 ++ .../matrix-js/src/matrix/client/shared.ts | 193 +++ .../matrix-js/src/matrix/client/storage.ts | 134 ++ .../matrix-js/src/matrix/client/types.ts | 39 + .../src/matrix/config-update.test.ts | 50 + .../matrix-js/src/matrix/config-update.ts | 102 ++ .../matrix-js/src/matrix/credentials.test.ts | 80 ++ .../matrix-js/src/matrix/credentials.ts | 123 ++ extensions/matrix-js/src/matrix/deps.ts | 157 ++ .../matrix-js/src/matrix/format.test.ts | 33 + extensions/matrix-js/src/matrix/format.ts | 22 + extensions/matrix-js/src/matrix/index.ts | 11 + .../src/matrix/monitor/allowlist.test.ts | 45 + .../matrix-js/src/matrix/monitor/allowlist.ts | 103 ++ .../src/matrix/monitor/auto-join.test.ts | 127 ++ .../matrix-js/src/matrix/monitor/auto-join.ts | 75 + .../matrix-js/src/matrix/monitor/direct.ts | 104 ++ .../src/matrix/monitor/events.test.ts | 208 +++ .../matrix-js/src/matrix/monitor/events.ts | 396 +++++ .../src/matrix/monitor/handler.test.ts | 308 ++++ .../matrix-js/src/matrix/monitor/handler.ts | 713 +++++++++ .../matrix-js/src/matrix/monitor/index.ts | 400 ++++++ .../matrix-js/src/matrix/monitor/location.ts | 100 ++ .../src/matrix/monitor/media.test.ts | 102 ++ .../matrix-js/src/matrix/monitor/media.ts | 117 ++ .../src/matrix/monitor/mentions.test.ts | 154 ++ .../matrix-js/src/matrix/monitor/mentions.ts | 52 + .../src/matrix/monitor/replies.test.ts | 132 ++ .../matrix-js/src/matrix/monitor/replies.ts | 100 ++ .../matrix-js/src/matrix/monitor/room-info.ts | 65 + .../src/matrix/monitor/rooms.test.ts | 39 + .../matrix-js/src/matrix/monitor/rooms.ts | 47 + .../matrix-js/src/matrix/monitor/threads.ts | 48 + .../matrix-js/src/matrix/monitor/types.ts | 28 + .../matrix/monitor/verification-utils.test.ts | 47 + .../src/matrix/monitor/verification-utils.ts | 44 + .../matrix-js/src/matrix/poll-types.test.ts | 21 + extensions/matrix-js/src/matrix/poll-types.ts | 167 +++ extensions/matrix-js/src/matrix/probe.test.ts | 53 + extensions/matrix-js/src/matrix/probe.ts | 70 + .../matrix-js/src/matrix/profile.test.ts | 123 ++ extensions/matrix-js/src/matrix/profile.ts | 143 ++ extensions/matrix-js/src/matrix/sdk.test.ts | 1279 +++++++++++++++++ extensions/matrix-js/src/matrix/sdk.ts | 1113 ++++++++++++++ .../src/matrix/sdk/crypto-bootstrap.test.ts | 355 +++++ .../src/matrix/sdk/crypto-bootstrap.ts | 334 +++++ .../src/matrix/sdk/crypto-facade.test.ts | 131 ++ .../matrix-js/src/matrix/sdk/crypto-facade.ts | 173 +++ .../src/matrix/sdk/decrypt-bridge.ts | 307 ++++ .../src/matrix/sdk/event-helpers.test.ts | 60 + .../matrix-js/src/matrix/sdk/event-helpers.ts | 71 + .../src/matrix/sdk/http-client.test.ts | 106 ++ .../matrix-js/src/matrix/sdk/http-client.ts | 63 + .../src/matrix/sdk/idb-persistence.ts | 164 +++ extensions/matrix-js/src/matrix/sdk/logger.ts | 98 ++ .../src/matrix/sdk/recovery-key-store.test.ts | 202 +++ .../src/matrix/sdk/recovery-key-store.ts | 294 ++++ .../matrix-js/src/matrix/sdk/transport.ts | 171 +++ extensions/matrix-js/src/matrix/sdk/types.ts | 217 +++ .../matrix/sdk/verification-manager.test.ts | 347 +++++ .../src/matrix/sdk/verification-manager.ts | 586 ++++++++ extensions/matrix-js/src/matrix/send.test.ts | 228 +++ extensions/matrix-js/src/matrix/send.ts | 264 ++++ .../matrix-js/src/matrix/send/client.test.ts | 78 + .../matrix-js/src/matrix/send/client.ts | 53 + .../matrix-js/src/matrix/send/formatting.ts | 93 ++ extensions/matrix-js/src/matrix/send/media.ts | 234 +++ .../matrix-js/src/matrix/send/targets.test.ts | 98 ++ .../matrix-js/src/matrix/send/targets.ts | 152 ++ extensions/matrix-js/src/matrix/send/types.ts | 109 ++ extensions/matrix-js/src/onboarding.test.ts | 163 +++ extensions/matrix-js/src/onboarding.ts | 563 ++++++++ extensions/matrix-js/src/outbound.ts | 55 + .../matrix-js/src/resolve-targets.test.ts | 92 ++ extensions/matrix-js/src/resolve-targets.ts | 149 ++ extensions/matrix-js/src/runtime.ts | 14 + extensions/matrix-js/src/tool-actions.ts | 353 +++++ extensions/matrix-js/src/types.ts | 121 ++ package.json | 4 + scripts/check-plugin-sdk-exports.mjs | 1 + scripts/release-check.ts | 2 + scripts/write-plugin-sdk-entry-dts.ts | 1 + src/plugin-sdk/matrix-js.ts | 11 + src/plugin-sdk/subpaths.test.ts | 1 + tsconfig.plugin-sdk.dts.json | 1 + tsdown.config.ts | 1 + vitest.config.ts | 1 + 140 files changed, 21472 insertions(+) create mode 100644 docs/channels/matrix-js.md create mode 100644 extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md create mode 100644 extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md create mode 100644 extensions/matrix-js/index.ts create mode 100644 extensions/matrix-js/openclaw.plugin.json create mode 100644 extensions/matrix-js/package.json create mode 100644 extensions/matrix-js/scripts/live-basic-send.ts create mode 100644 extensions/matrix-js/scripts/live-common.ts create mode 100644 extensions/matrix-js/scripts/live-cross-signing-probe.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-bootstrap.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-room-state.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-send-room.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-send.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-status.ts create mode 100644 extensions/matrix-js/scripts/live-e2ee-wait-reply.ts create mode 100644 extensions/matrix-js/scripts/live-read-room.ts create mode 100644 extensions/matrix-js/src/actions.ts create mode 100644 extensions/matrix-js/src/channel.directory.test.ts create mode 100644 extensions/matrix-js/src/channel.ts create mode 100644 extensions/matrix-js/src/cli.test.ts create mode 100644 extensions/matrix-js/src/cli.ts create mode 100644 extensions/matrix-js/src/config-migration.ts create mode 100644 extensions/matrix-js/src/config-schema.ts create mode 100644 extensions/matrix-js/src/directory-live.test.ts create mode 100644 extensions/matrix-js/src/directory-live.ts create mode 100644 extensions/matrix-js/src/group-mentions.ts create mode 100644 extensions/matrix-js/src/matrix/account-config.ts create mode 100644 extensions/matrix-js/src/matrix/accounts.test.ts create mode 100644 extensions/matrix-js/src/matrix/accounts.ts create mode 100644 extensions/matrix-js/src/matrix/actions.ts create mode 100644 extensions/matrix-js/src/matrix/actions/client.test.ts create mode 100644 extensions/matrix-js/src/matrix/actions/client.ts create mode 100644 extensions/matrix-js/src/matrix/actions/limits.test.ts create mode 100644 extensions/matrix-js/src/matrix/actions/limits.ts create mode 100644 extensions/matrix-js/src/matrix/actions/messages.ts create mode 100644 extensions/matrix-js/src/matrix/actions/pins.test.ts create mode 100644 extensions/matrix-js/src/matrix/actions/pins.ts create mode 100644 extensions/matrix-js/src/matrix/actions/profile.ts create mode 100644 extensions/matrix-js/src/matrix/actions/reactions.test.ts create mode 100644 extensions/matrix-js/src/matrix/actions/reactions.ts create mode 100644 extensions/matrix-js/src/matrix/actions/room.ts create mode 100644 extensions/matrix-js/src/matrix/actions/summary.ts create mode 100644 extensions/matrix-js/src/matrix/actions/types.ts create mode 100644 extensions/matrix-js/src/matrix/actions/verification.ts create mode 100644 extensions/matrix-js/src/matrix/active-client.ts create mode 100644 extensions/matrix-js/src/matrix/client-bootstrap.ts create mode 100644 extensions/matrix-js/src/matrix/client.test.ts create mode 100644 extensions/matrix-js/src/matrix/client.ts create mode 100644 extensions/matrix-js/src/matrix/client/config.ts create mode 100644 extensions/matrix-js/src/matrix/client/create-client.ts create mode 100644 extensions/matrix-js/src/matrix/client/logging.ts create mode 100644 extensions/matrix-js/src/matrix/client/runtime.ts create mode 100644 extensions/matrix-js/src/matrix/client/shared.test.ts create mode 100644 extensions/matrix-js/src/matrix/client/shared.ts create mode 100644 extensions/matrix-js/src/matrix/client/storage.ts create mode 100644 extensions/matrix-js/src/matrix/client/types.ts create mode 100644 extensions/matrix-js/src/matrix/config-update.test.ts create mode 100644 extensions/matrix-js/src/matrix/config-update.ts create mode 100644 extensions/matrix-js/src/matrix/credentials.test.ts create mode 100644 extensions/matrix-js/src/matrix/credentials.ts create mode 100644 extensions/matrix-js/src/matrix/deps.ts create mode 100644 extensions/matrix-js/src/matrix/format.test.ts create mode 100644 extensions/matrix-js/src/matrix/format.ts create mode 100644 extensions/matrix-js/src/matrix/index.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/allowlist.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/allowlist.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/auto-join.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/auto-join.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/direct.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/events.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/events.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/handler.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/handler.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/index.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/location.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/media.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/media.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/mentions.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/mentions.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/replies.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/replies.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/room-info.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/rooms.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/rooms.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/threads.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/types.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts create mode 100644 extensions/matrix-js/src/matrix/monitor/verification-utils.ts create mode 100644 extensions/matrix-js/src/matrix/poll-types.test.ts create mode 100644 extensions/matrix-js/src/matrix/poll-types.ts create mode 100644 extensions/matrix-js/src/matrix/probe.test.ts create mode 100644 extensions/matrix-js/src/matrix/probe.ts create mode 100644 extensions/matrix-js/src/matrix/profile.test.ts create mode 100644 extensions/matrix-js/src/matrix/profile.ts create mode 100644 extensions/matrix-js/src/matrix/sdk.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/crypto-facade.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/event-helpers.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/http-client.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/http-client.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/idb-persistence.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/logger.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/transport.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/types.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts create mode 100644 extensions/matrix-js/src/matrix/sdk/verification-manager.ts create mode 100644 extensions/matrix-js/src/matrix/send.test.ts create mode 100644 extensions/matrix-js/src/matrix/send.ts create mode 100644 extensions/matrix-js/src/matrix/send/client.test.ts create mode 100644 extensions/matrix-js/src/matrix/send/client.ts create mode 100644 extensions/matrix-js/src/matrix/send/formatting.ts create mode 100644 extensions/matrix-js/src/matrix/send/media.ts create mode 100644 extensions/matrix-js/src/matrix/send/targets.test.ts create mode 100644 extensions/matrix-js/src/matrix/send/targets.ts create mode 100644 extensions/matrix-js/src/matrix/send/types.ts create mode 100644 extensions/matrix-js/src/onboarding.test.ts create mode 100644 extensions/matrix-js/src/onboarding.ts create mode 100644 extensions/matrix-js/src/outbound.ts create mode 100644 extensions/matrix-js/src/resolve-targets.test.ts create mode 100644 extensions/matrix-js/src/resolve-targets.ts create mode 100644 extensions/matrix-js/src/runtime.ts create mode 100644 extensions/matrix-js/src/tool-actions.ts create mode 100644 extensions/matrix-js/src/types.ts create mode 100644 src/plugin-sdk/matrix-js.ts diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md new file mode 100644 index 00000000000..2ad516f6e72 --- /dev/null +++ b/docs/channels/matrix-js.md @@ -0,0 +1,305 @@ +--- +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", + }, + }, +} +``` + +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. + +## 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`. + +## 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`. +- `textChunkLimit`: outbound message chunk size. +- `chunkMode`: `length` or `newline`. +- `responsePrefix`: optional message prefix for outbound replies. +- `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/tools/plugin.md b/docs/tools/plugin.md index a257d8b7a45..88c39797f00 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -166,6 +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/mattermost`, `openclaw/plugin-sdk/memory-core`, `openclaw/plugin-sdk/memory-lancedb`, `openclaw/plugin-sdk/minimax-portal-auth`, diff --git a/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md b/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md new file mode 100644 index 00000000000..20c665f306b --- /dev/null +++ b/extensions/matrix-js/LEGACY_MATRIX_PARITY_GAP_AUDIT.md @@ -0,0 +1,120 @@ +# 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 new file mode 100644 index 00000000000..75b1ff25a7b --- /dev/null +++ b/extensions/matrix-js/LEGACY_MATRIX_PARITY_SPEC.md @@ -0,0 +1,397 @@ +# 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 new file mode 100644 index 00000000000..23d8b53c1e1 --- /dev/null +++ b/extensions/matrix-js/index.ts @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000000..6044d2ad998 --- /dev/null +++ b/extensions/matrix-js/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "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 new file mode 100644 index 00000000000..63c7a7b54fb --- /dev/null +++ b/extensions/matrix-js/package.json @@ -0,0 +1,37 @@ +{ + "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/scripts/live-basic-send.ts b/extensions/matrix-js/scripts/live-basic-send.ts new file mode 100644 index 00000000000..535133e6fb2 --- /dev/null +++ b/extensions/matrix-js/scripts/live-basic-send.ts @@ -0,0 +1,104 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + const targetUserId = process.argv[2]?.trim() || "@user:example.org"; + const stamp = new Date().toISOString(); + + try { + const dmRoomCreate = (await client.doRequest( + "POST", + "/_matrix/client/v3/createRoom", + undefined, + { + is_direct: true, + invite: [targetUserId], + preset: "trusted_private_chat", + name: `OpenClaw DM Test ${stamp}`, + topic: "matrix-js basic DM messaging test", + }, + )) as { room_id?: string }; + + const dmRoomId = dmRoomCreate.room_id?.trim() ?? ""; + if (!dmRoomId) { + throw new Error("Failed to create DM room"); + } + + const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ?? + {}) as Record; + const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : []; + await client.setAccountData("m.direct", { + ...currentDirect, + [targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)], + }); + + const dmByUserTarget = await sendMatrixMessage( + targetUserId, + `Matrix-js basic DM test (user target) ${stamp}`, + { client }, + ); + const dmByRoomTarget = await sendMatrixMessage( + dmRoomId, + `Matrix-js basic DM test (room target) ${stamp}`, + { client }, + ); + + const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, { + invite: [targetUserId], + preset: "private_chat", + name: `OpenClaw Room Test ${stamp}`, + topic: "matrix-js basic room messaging test", + })) as { room_id?: string }; + + const roomId = roomCreate.room_id?.trim() ?? ""; + if (!roomId) { + throw new Error("Failed to create room chat room"); + } + + const roomSend = await sendMatrixMessage(roomId, `Matrix-js basic room test ${stamp}`, { + client, + }); + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + senderUserId: base.userId, + targetUserId, + dm: { + roomId: dmRoomId, + userTargetMessageId: dmByUserTarget.messageId, + roomTargetMessageId: dmByRoomTarget.messageId, + }, + room: { + roomId, + messageId: roomSend.messageId, + }, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`BASIC_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-common.ts b/extensions/matrix-js/scripts/live-common.ts new file mode 100644 index 00000000000..6c9e9cf7dcf --- /dev/null +++ b/extensions/matrix-js/scripts/live-common.ts @@ -0,0 +1,145 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setMatrixRuntime } from "../src/runtime.js"; + +type EnvMap = Record; + +function loadEnvFile(filePath: string): EnvMap { + const out: EnvMap = {}; + if (!fs.existsSync(filePath)) { + return out; + } + const raw = fs.readFileSync(filePath, "utf8"); + for (const lineRaw of raw.split(/\r?\n/)) { + const line = lineRaw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const idx = line.indexOf("="); + if (idx <= 0) { + continue; + } + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +function normalizeHomeserver(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +function chunkText(text: string, limit: number): string[] { + if (!text) { + return []; + } + if (text.length <= limit) { + return [text]; + } + const out: string[] = []; + for (let i = 0; i < text.length; i += limit) { + out.push(text.slice(i, i + limit)); + } + return out; +} + +export type LiveHarnessConfig = { + homeserver: string; + userId: string; + password: string; +}; + +export function resolveLiveHarnessConfig(): LiveHarnessConfig { + const envFromFile = loadEnvFile(path.join(os.homedir(), ".openclaw", ".env")); + const homeserver = normalizeHomeserver( + process.env.MATRIX_HOMESERVER ?? envFromFile.MATRIX_HOMESERVER ?? "", + ); + const userId = process.env.MATRIX_USER_ID ?? envFromFile.MATRIX_USER_ID ?? ""; + const password = process.env.MATRIX_PASSWORD ?? envFromFile.MATRIX_PASSWORD ?? ""; + + if (!homeserver || !userId || !password) { + throw new Error("Missing MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_PASSWORD"); + } + + return { + homeserver, + userId, + password, + }; +} + +export function installLiveHarnessRuntime(cfg: LiveHarnessConfig): { + channels: { + "matrix-js": { + homeserver: string; + userId: string; + password: string; + encryption: false; + }; + }; +} { + const pluginCfg = { + channels: { + "matrix-js": { + homeserver: cfg.homeserver, + userId: cfg.userId, + password: cfg.password, + encryption: false as const, + }, + }, + }; + + setMatrixRuntime({ + config: { + loadConfig: () => pluginCfg, + }, + state: { + resolveStateDir: () => path.join(os.homedir(), ".openclaw", "matrix-js-live-harness-state"), + }, + channel: { + text: { + resolveMarkdownTableMode: () => "off", + convertMarkdownTables: (text: string) => text, + resolveTextChunkLimit: () => 4000, + resolveChunkMode: () => "off", + chunkMarkdownTextWithMode: (text: string, limit: number) => chunkText(text, limit), + }, + }, + media: { + mediaKindFromMime: (mime: string) => { + const value = (mime || "").toLowerCase(); + if (value.startsWith("image/")) { + return "image"; + } + if (value.startsWith("audio/")) { + return "audio"; + } + if (value.startsWith("video/")) { + return "video"; + } + return "document"; + }, + isVoiceCompatibleAudio: () => false, + loadWebMedia: async () => ({ + buffer: Buffer.from("matrix-js harness media payload\n", "utf8"), + contentType: "text/plain", + fileName: "matrix-js-harness.txt", + kind: "document" as const, + }), + }, + } as never); + + return pluginCfg; +} diff --git a/extensions/matrix-js/scripts/live-cross-signing-probe.ts b/extensions/matrix-js/scripts/live-cross-signing-probe.ts new file mode 100644 index 00000000000..65095f37f53 --- /dev/null +++ b/extensions/matrix-js/scripts/live-cross-signing-probe.ts @@ -0,0 +1,126 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +type MatrixCryptoProbe = { + isCrossSigningReady?: () => Promise; + userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; + bootstrapCrossSigning?: (opts: { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }) => Promise; +}; + +async function main() { + const base = resolveLiveHarnessConfig(); + const cfg = installLiveHarnessRuntime(base); + (cfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: cfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + const initCrypto = (client as unknown as { initializeCryptoIfNeeded?: () => Promise }) + .initializeCryptoIfNeeded; + if (typeof initCrypto === "function") { + await initCrypto.call(client); + } + + const inner = (client as unknown as { client?: { getCrypto?: () => unknown } }).client; + const crypto = (inner?.getCrypto?.() ?? null) as MatrixCryptoProbe | null; + const userId = auth.userId; + const password = auth.password; + + const out: Record = { + userId, + hasCrypto: Boolean(crypto), + readyBefore: null, + hasKeysBefore: null, + bootstrap: "skipped", + readyAfter: null, + hasKeysAfter: null, + queryHasMaster: null, + queryHasSelfSigning: null, + queryHasUserSigning: null, + }; + + if (!crypto || !crypto.bootstrapCrossSigning) { + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + if (typeof crypto.isCrossSigningReady === "function") { + out.readyBefore = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`); + } + if (typeof crypto.userHasCrossSigningKeys === "function") { + out.hasKeysBefore = await crypto + .userHasCrossSigningKeys(userId, true) + .catch((err) => `error:${String(err)}`); + } + + const authUploadDeviceSigningKeys = async ( + makeRequest: (authData: Record | null) => Promise, + ): Promise => { + try { + return await makeRequest(null); + } catch { + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!password?.trim()) { + throw new Error("Missing password for m.login.password fallback"); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: userId }, + password, + }); + } + } + }; + + try { + await crypto.bootstrapCrossSigning({ authUploadDeviceSigningKeys }); + out.bootstrap = "ok"; + } catch (err) { + out.bootstrap = "error"; + out.bootstrapError = err instanceof Error ? err.message : String(err); + } + + if (typeof crypto.isCrossSigningReady === "function") { + out.readyAfter = await crypto.isCrossSigningReady().catch((err) => `error:${String(err)}`); + } + if (typeof crypto.userHasCrossSigningKeys === "function") { + out.hasKeysAfter = await crypto + .userHasCrossSigningKeys(userId, true) + .catch((err) => `error:${String(err)}`); + } + + const query = (await client.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, { + device_keys: { [userId]: [] }, + })) as { + master_keys?: Record; + self_signing_keys?: Record; + user_signing_keys?: Record; + }; + + out.queryHasMaster = Boolean(query.master_keys?.[userId]); + out.queryHasSelfSigning = Boolean(query.self_signing_keys?.[userId]); + out.queryHasUserSigning = Boolean(query.user_signing_keys?.[userId]); + + process.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + client.stop(); +} + +main().catch((err) => { + process.stderr.write( + `CROSS_SIGNING_PROBE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts b/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts new file mode 100644 index 00000000000..c52e7f922fa --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-bootstrap.ts @@ -0,0 +1,28 @@ +import { bootstrapMatrixVerification } from "../src/matrix/actions/verification.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const recoveryKeyArg = process.argv[2]; + const forceResetCrossSigning = process.argv.includes("--force-reset-cross-signing"); + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const result = await bootstrapMatrixVerification({ + recoveryKey: recoveryKeyArg?.trim() || undefined, + forceResetCrossSigning, + }); + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.success) { + process.exitCode = 1; + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_BOOTSTRAP_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-room-state.ts b/extensions/matrix-js/scripts/live-e2ee-room-state.ts new file mode 100644 index 00000000000..af71d30be77 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-room-state.ts @@ -0,0 +1,65 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const roomId = process.argv[2]?.trim(); + const eventId = process.argv[3]?.trim(); + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/scripts/live-e2ee-room-state.ts [eventId]", + ); + } + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + try { + const encryptionState = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`, + )) as { algorithm?: string; rotation_period_ms?: number; rotation_period_msgs?: number }; + + let eventType: string | null = null; + if (eventId) { + const event = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, + )) as { type?: string }; + eventType = event.type ?? null; + } + + process.stdout.write( + `${JSON.stringify( + { + roomId, + encryptionState, + eventId: eventId ?? null, + eventType, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_ROOM_STATE_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-send-room.ts b/extensions/matrix-js/scripts/live-e2ee-send-room.ts new file mode 100644 index 00000000000..f11510da077 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-send-room.ts @@ -0,0 +1,99 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main() { + const roomId = process.argv[2]?.trim(); + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + const settleMsRaw = Number.parseInt(process.argv[3] ?? "4000", 10); + const settleMs = Number.isFinite(settleMsRaw) && settleMsRaw >= 0 ? settleMsRaw : 4000; + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/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; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + const stamp = new Date().toISOString(); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + if (settleMs > 0) { + await delay(settleMs); + } + + const sent = await sendMatrixMessage( + roomId, + `Matrix-js E2EE existing-room test ${stamp} (settleMs=${settleMs})`, + { client }, + ); + + const event = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(sent.messageId)}`, + )) as { type?: string }; + + process.stdout.write( + `${JSON.stringify( + { + roomId, + messageId: sent.messageId, + storedEventType: event.type ?? null, + fullBootstrap: useFullBootstrap, + settleMs, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_SEND_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-send.ts b/extensions/matrix-js/scripts/live-e2ee-send.ts new file mode 100644 index 00000000000..9f9e71738e9 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-send.ts @@ -0,0 +1,169 @@ +import { sendMatrixMessage } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +const MEGOLM_ALG = "m.megolm.v1.aes-sha2"; + +type MatrixEventLike = { + type?: string; +}; + +async function main() { + const targetUserId = process.argv[2]?.trim() || "@user:example.org"; + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + + // Enable encryption for this run only. + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + const stamp = new Date().toISOString(); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + const dmRoomCreate = (await client.doRequest( + "POST", + "/_matrix/client/v3/createRoom", + undefined, + { + is_direct: true, + invite: [targetUserId], + preset: "trusted_private_chat", + name: `OpenClaw E2EE DM ${stamp}`, + topic: "matrix-js E2EE DM test", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: MEGOLM_ALG, + }, + }, + ], + }, + )) as { room_id?: string }; + + const dmRoomId = dmRoomCreate.room_id?.trim() ?? ""; + if (!dmRoomId) { + throw new Error("Failed to create encrypted DM room"); + } + + const currentDirect = ((await client.getAccountData("m.direct").catch(() => ({}))) ?? + {}) as Record; + const existing = Array.isArray(currentDirect[targetUserId]) ? currentDirect[targetUserId] : []; + await client.setAccountData("m.direct", { + ...currentDirect, + [targetUserId]: [dmRoomId, ...existing.filter((id) => id !== dmRoomId)], + }); + + const dmSend = await sendMatrixMessage( + dmRoomId, + `Matrix-js E2EE DM test ${stamp}\nPlease reply here so I can validate decrypt/read.`, + { + client, + }, + ); + + const roomCreate = (await client.doRequest("POST", "/_matrix/client/v3/createRoom", undefined, { + invite: [targetUserId], + preset: "private_chat", + name: `OpenClaw E2EE Room ${stamp}`, + topic: "matrix-js E2EE room test", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: MEGOLM_ALG, + }, + }, + ], + })) as { room_id?: string }; + + const roomId = roomCreate.room_id?.trim() ?? ""; + if (!roomId) { + throw new Error("Failed to create encrypted room chat"); + } + + const roomSend = await sendMatrixMessage( + roomId, + `Matrix-js E2EE room test ${stamp}\nPlease reply here too.`, + { + client, + }, + ); + + const dmRaw = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(dmRoomId)}/event/${encodeURIComponent(dmSend.messageId)}`, + )) as MatrixEventLike; + + const roomRaw = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(roomSend.messageId)}`, + )) as MatrixEventLike; + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + senderUserId: base.userId, + targetUserId, + encryptionAlgorithm: MEGOLM_ALG, + fullBootstrap: useFullBootstrap, + dm: { + roomId: dmRoomId, + messageId: dmSend.messageId, + storedEventType: dmRaw.type ?? null, + }, + room: { + roomId, + messageId: roomSend.messageId, + storedEventType: roomRaw.type ?? null, + }, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`E2EE_SEND_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-status.ts b/extensions/matrix-js/scripts/live-e2ee-status.ts new file mode 100644 index 00000000000..0096da3b78b --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-status.ts @@ -0,0 +1,57 @@ +import { + getMatrixEncryptionStatus, + getMatrixVerificationStatus, + verifyMatrixRecoveryKey, +} from "../src/matrix/actions.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +async function main() { + const includeRecoveryKey = process.argv.includes("--include-recovery-key"); + const verifyStoredRecoveryKey = process.argv.includes("--verify-stored-recovery-key"); + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + (pluginCfg.channels["matrix-js"] as { encryption: boolean }).encryption = true; + + const verification = await getMatrixVerificationStatus({ + includeRecoveryKey, + }); + const encryption = await getMatrixEncryptionStatus({ + includeRecoveryKey, + }); + + let recoveryVerificationResult: unknown = null; + if (verifyStoredRecoveryKey) { + const key = + verification && typeof verification === "object" && "recoveryKey" in verification + ? (verification as { recoveryKey?: string | null }).recoveryKey + : null; + if (key?.trim()) { + recoveryVerificationResult = await verifyMatrixRecoveryKey(key); + } else { + recoveryVerificationResult = { + success: false, + error: "No stored recovery key returned (use --include-recovery-key)", + }; + } + } + + process.stdout.write( + `${JSON.stringify( + { + homeserver: base.homeserver, + userId: base.userId, + verification, + encryption, + recoveryVerificationResult, + }, + null, + 2, + )}\n`, + ); +} + +main().catch((err) => { + process.stderr.write(`E2EE_STATUS_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts b/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts new file mode 100644 index 00000000000..ad57cca0da0 --- /dev/null +++ b/extensions/matrix-js/scripts/live-e2ee-wait-reply.ts @@ -0,0 +1,122 @@ +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +type MatrixRawEvent = { + event_id?: string; + type?: string; + sender?: string; + room_id?: string; + origin_server_ts?: number; + content?: { + body?: string; + msgtype?: string; + }; +}; + +async function main() { + const roomId = process.argv[2]?.trim(); + const targetUserId = process.argv[3]?.trim() || "@user:example.org"; + const timeoutSecRaw = Number.parseInt(process.argv[4] ?? "120", 10); + const timeoutMs = + (Number.isFinite(timeoutSecRaw) && timeoutSecRaw > 0 ? timeoutSecRaw : 120) * 1000; + const useFullBootstrap = process.argv.includes("--full-bootstrap"); + const startupTimeoutMs = 45_000; + + if (!roomId) { + throw new Error( + "Usage: node --import tsx extensions/matrix-js/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; + + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: true, + }); + + try { + if (!useFullBootstrap) { + const bootstrapper = ( + client as unknown as { cryptoBootstrapper?: { bootstrap?: () => Promise } } + ).cryptoBootstrapper; + if (bootstrapper?.bootstrap) { + bootstrapper.bootstrap = async () => {}; + } + } + + await Promise.race([ + client.start(), + new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Matrix client start timed out after ${startupTimeoutMs}ms (fullBootstrap=${useFullBootstrap})`, + ), + ); + }, startupTimeoutMs); + }), + ]); + + const found = await new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(null); + }, timeoutMs); + + client.on("room.message", (eventRoomId, event) => { + const rid = String(eventRoomId || ""); + const raw = event as MatrixRawEvent; + if (rid !== roomId) { + return; + } + if ((raw.sender ?? "").trim() !== targetUserId) { + return; + } + if ((raw.type ?? "").trim() !== "m.room.message") { + return; + } + clearTimeout(timer); + resolve(raw); + }); + }); + + process.stdout.write( + `${JSON.stringify( + { + roomId, + targetUserId, + timeoutMs, + found: Boolean(found), + message: found + ? { + eventId: found.event_id ?? null, + type: found.type ?? null, + sender: found.sender ?? null, + timestamp: found.origin_server_ts ?? null, + text: found.content?.body ?? null, + msgtype: found.content?.msgtype ?? null, + } + : null, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write( + `E2EE_WAIT_REPLY_ERROR: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +}); diff --git a/extensions/matrix-js/scripts/live-read-room.ts b/extensions/matrix-js/scripts/live-read-room.ts new file mode 100644 index 00000000000..27607fc9412 --- /dev/null +++ b/extensions/matrix-js/scripts/live-read-room.ts @@ -0,0 +1,56 @@ +import { readMatrixMessages } from "../src/matrix/actions.js"; +import { createMatrixClient, resolveMatrixAuth } from "../src/matrix/client.js"; +import { installLiveHarnessRuntime, resolveLiveHarnessConfig } from "./live-common.js"; + +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]"); + } + + const requestedLimit = Number.parseInt(process.argv[3] ?? "30", 10); + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : 30; + + const base = resolveLiveHarnessConfig(); + const pluginCfg = installLiveHarnessRuntime(base); + const auth = await resolveMatrixAuth({ cfg: pluginCfg as never }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + password: auth.password, + deviceId: auth.deviceId, + encryption: false, + }); + + try { + const result = await readMatrixMessages(roomId, { client, limit }); + const compact = result.messages.map((msg) => ({ + id: msg.eventId, + sender: msg.sender, + ts: msg.timestamp, + text: msg.body ?? "", + })); + + process.stdout.write( + `${JSON.stringify( + { + roomId, + count: compact.length, + messages: compact, + nextBatch: result.nextBatch ?? null, + prevBatch: result.prevBatch ?? null, + }, + null, + 2, + )}\n`, + ); + } finally { + client.stop(); + } +} + +main().catch((err) => { + process.stderr.write(`READ_ROOM_ERROR: ${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/extensions/matrix-js/src/actions.ts b/extensions/matrix-js/src/actions.ts new file mode 100644 index 00000000000..f18226bd0a2 --- /dev/null +++ b/extensions/matrix-js/src/actions.ts @@ -0,0 +1,242 @@ +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"; + +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 = new Set(["send", "poll"]); + if (gate("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (gate("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (gate("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (gate("memberInfo")) { + actions.add("member-info"); + } + if (gate("channelInfo")) { + actions.add("channel-info"); + } + if (account.config.encryption === true && gate("verification")) { + actions.add("permissions"); + } + return Array.from(actions); + }, + supportsAction: ({ action }) => action !== "poll", + 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 } = ctx; + 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 handleMatrixAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToId: replyTo ?? undefined, + threadId: threadId ?? undefined, + }, + cfg as CoreConfig, + ); + } + + 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, + ); + } + + 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, + ); + } + + 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, + ); + } + + 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, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleMatrixAction( + { + action: "deleteMessage", + roomId: resolveRoomId(), + messageId, + }, + cfg as CoreConfig, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + 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, + ); + } + + 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, + ); + } + + if (action === "channel-info") { + return await handleMatrixAction( + { + action: "channelInfo", + roomId: resolveRoomId(), + }, + cfg as CoreConfig, + ); + } + + 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 handleMatrixAction( + { + ...params, + action: resolvedAction, + }, + cfg, + ); + } + + 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 new file mode 100644 index 00000000000..18b02e5c0f7 --- /dev/null +++ b/extensions/matrix-js/src/channel.directory.test.ts @@ -0,0 +1,397 @@ +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("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 new file mode 100644 index 00000000000..a625f2cfc49 --- /dev/null +++ b/extensions/matrix-js/src/channel.ts @@ -0,0 +1,490 @@ +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 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) : context.ReplyToId, + 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/cli.test.ts b/extensions/matrix-js/src/cli.test.ts new file mode 100644 index 00000000000..36c3efbf6af --- /dev/null +++ b/extensions/matrix-js/src/cli.test.ts @@ -0,0 +1,527 @@ +import { Command } from "commander"; +import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const bootstrapMatrixVerificationMock = vi.fn(); +const getMatrixRoomKeyBackupStatusMock = vi.fn(); +const getMatrixVerificationStatusMock = vi.fn(); +const matrixSetupApplyAccountConfigMock = vi.fn(); +const matrixSetupValidateInputMock = vi.fn(); +const matrixRuntimeLoadConfigMock = vi.fn(); +const matrixRuntimeWriteConfigFileMock = vi.fn(); +const restoreMatrixRoomKeyBackupMock = vi.fn(); +const setMatrixSdkLogModeMock = vi.fn(); +const updateMatrixOwnProfileMock = vi.fn(); +const verifyMatrixRecoveryKeyMock = vi.fn(); + +vi.mock("./matrix/actions/verification.js", () => ({ + bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args), + getMatrixRoomKeyBackupStatus: (...args: unknown[]) => getMatrixRoomKeyBackupStatusMock(...args), + getMatrixVerificationStatus: (...args: unknown[]) => getMatrixVerificationStatusMock(...args), + restoreMatrixRoomKeyBackup: (...args: unknown[]) => restoreMatrixRoomKeyBackupMock(...args), + verifyMatrixRecoveryKey: (...args: unknown[]) => verifyMatrixRecoveryKeyMock(...args), +})); + +vi.mock("./matrix/client/logging.js", () => ({ + setMatrixSdkLogMode: (...args: unknown[]) => setMatrixSdkLogModeMock(...args), +})); + +vi.mock("./matrix/actions/profile.js", () => ({ + updateMatrixOwnProfile: (...args: unknown[]) => updateMatrixOwnProfileMock(...args), +})); + +vi.mock("./channel.js", () => ({ + matrixPlugin: { + setup: { + applyAccountConfig: (...args: unknown[]) => matrixSetupApplyAccountConfigMock(...args), + validateInput: (...args: unknown[]) => matrixSetupValidateInputMock(...args), + }, + }, +})); + +vi.mock("./runtime.js", () => ({ + getMatrixRuntime: () => ({ + config: { + loadConfig: (...args: unknown[]) => matrixRuntimeLoadConfigMock(...args), + writeConfigFile: (...args: unknown[]) => matrixRuntimeWriteConfigFileMock(...args), + }, + }), +})); + +let registerMatrixJsCli: typeof import("./cli.js").registerMatrixJsCli; + +function buildProgram(): Command { + const program = new Command(); + registerMatrixJsCli({ program }); + return program; +} + +function formatExpectedLocalTimestamp(value: string): string { + return formatZonedTimestamp(new Date(value), { displaySeconds: true }) ?? value; +} + +describe("matrix-js CLI verification commands", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + process.exitCode = undefined; + ({ registerMatrixJsCli } = await import("./cli.js")); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + matrixSetupValidateInputMock.mockReturnValue(null); + matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg); + matrixRuntimeLoadConfigMock.mockReturnValue({}); + matrixRuntimeWriteConfigFileMock.mockResolvedValue(undefined); + updateMatrixOwnProfileMock.mockResolvedValue({ + skipped: false, + displayNameUpdated: true, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("sets non-zero exit code for device verification failures in JSON mode", async () => { + verifyMatrixRecoveryKeyMock.mockResolvedValue({ + success: false, + error: "invalid key", + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "device", "bad-key", "--json"], { + from: "user", + }); + + expect(process.exitCode).toBe(1); + }); + + it("sets non-zero exit code for bootstrap failures in JSON mode", async () => { + bootstrapMatrixVerificationMock.mockResolvedValue({ + success: false, + error: "bootstrap failed", + verification: {}, + crossSigning: {}, + pendingVerifications: 0, + cryptoBootstrap: null, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" }); + + expect(process.exitCode).toBe(1); + }); + + it("sets non-zero exit code for backup restore failures in JSON mode", async () => { + restoreMatrixRoomKeyBackupMock.mockResolvedValue({ + success: false, + error: "missing backup key", + backupVersion: null, + imported: 0, + total: 0, + loadedFromSecretStorage: false, + backup: { + serverVersion: "1", + activeVersion: null, + trusted: true, + matchesDecryptionKey: false, + decryptionKeyCached: false, + }, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "backup", "restore", "--json"], { + from: "user", + }); + + expect(process.exitCode).toBe(1); + }); + + it("adds a matrix-js 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": { + accounts: { + [accountId]: { + homeserver: "https://matrix.example.org", + }, + }, + }, + }, + }), + ); + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix-js", + "account", + "add", + "--account", + "Ops", + "--homeserver", + "https://matrix.example.org", + "--user-id", + "@ops:example.org", + "--password", + "secret", + ], + { from: "user" }, + ); + + expect(matrixSetupValidateInputMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "ops", + input: expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@ops:example.org", + password: "secret", + }), + }), + ); + expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + "matrix-js": { + accounts: { + ops: expect.objectContaining({ + homeserver: "https://matrix.example.org", + }), + }, + }, + }, + }), + ); + expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops"); + expect(console.log).toHaveBeenCalledWith( + "Bind this account to an agent: openclaw agents bind --agent --bind matrix-js:ops", + ); + }); + + it("uses --name as fallback account id and prints account-scoped config path", async () => { + matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} }); + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix-js", + "account", + "add", + "--name", + "Main Bot", + "--homeserver", + "https://matrix.example.org", + "--user-id", + "@main:example.org", + "--password", + "secret", + ], + { from: "user" }, + ); + + expect(matrixSetupValidateInputMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "main-bot", + }), + ); + expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: main-bot"); + expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.accounts.main-bot"); + expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "main-bot", + displayName: "Main Bot", + }), + ); + expect(console.log).toHaveBeenCalledWith( + "Bind this account to an agent: openclaw agents bind --agent --bind matrix-js:main-bot", + ); + }); + + it("sets profile name and avatar via profile set command", async () => { + const program = buildProgram(); + + await program.parseAsync( + [ + "matrix-js", + "profile", + "set", + "--account", + "alerts", + "--name", + "Alerts Bot", + "--avatar-url", + "mxc://example/avatar", + ], + { from: "user" }, + ); + + expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "alerts", + displayName: "Alerts Bot", + avatarUrl: "mxc://example/avatar", + }), + ); + expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith("Account: alerts"); + expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix-js.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"], { + from: "user", + }); + + expect(process.exitCode).toBe(1); + expect(console.log).toHaveBeenCalledWith( + expect.stringContaining('"error": "Matrix requires --homeserver"'), + ); + }); + + it("keeps zero exit code for successful bootstrap in JSON mode", async () => { + process.exitCode = 0; + bootstrapMatrixVerificationMock.mockResolvedValue({ + success: true, + verification: {}, + crossSigning: {}, + pendingVerifications: 0, + cryptoBootstrap: {}, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "bootstrap", "--json"], { from: "user" }); + + expect(process.exitCode).toBe(0); + }); + + it("prints local timezone timestamps for verify status output in verbose mode", async () => { + const recoveryCreatedAt = "2026-02-25T20:10:11.000Z"; + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "1", + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: recoveryCreatedAt, + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "status", "--verbose"], { from: "user" }); + + expect(console.log).toHaveBeenCalledWith( + `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, + ); + expect(console.log).toHaveBeenCalledWith("Diagnostics:"); + expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default"); + }); + + it("prints local timezone timestamps for verify bootstrap and device output in verbose mode", async () => { + const recoveryCreatedAt = "2026-02-25T20:10:11.000Z"; + const verifiedAt = "2026-02-25T20:14:00.000Z"; + bootstrapMatrixVerificationMock.mockResolvedValue({ + success: true, + verification: { + encryptionEnabled: true, + verified: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "1", + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + recoveryKeyStored: true, + recoveryKeyId: "SSSS", + recoveryKeyCreatedAt: recoveryCreatedAt, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + }, + crossSigning: { + published: true, + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + }, + pendingVerifications: 0, + cryptoBootstrap: {}, + }); + verifyMatrixRecoveryKeyMock.mockResolvedValue({ + success: true, + encryptionEnabled: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "1", + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + recoveryKeyStored: true, + recoveryKeyId: "SSSS", + recoveryKeyCreatedAt: recoveryCreatedAt, + verifiedAt, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "bootstrap", "--verbose"], { + from: "user", + }); + await program.parseAsync(["matrix-js", "verify", "device", "valid-key", "--verbose"], { + from: "user", + }); + + expect(console.log).toHaveBeenCalledWith( + `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, + ); + expect(console.log).toHaveBeenCalledWith( + `Verified at: ${formatExpectedLocalTimestamp(verifiedAt)}`, + ); + }); + + it("keeps default output concise when verbose is not provided", async () => { + const recoveryCreatedAt = "2026-02-25T20:10:11.000Z"; + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "1", + backup: { + serverVersion: "1", + activeVersion: "1", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: recoveryCreatedAt, + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" }); + + expect(console.log).not.toHaveBeenCalledWith( + `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, + ); + expect(console.log).not.toHaveBeenCalledWith("Pending verifications: 0"); + expect(console.log).not.toHaveBeenCalledWith("Diagnostics:"); + expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device"); + expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("quiet"); + }); + + it("shows explicit backup issue in default status output", async () => { + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "5256", + backup: { + serverVersion: "5256", + activeVersion: null, + trusted: true, + matchesDecryptionKey: false, + decryptionKeyCached: false, + keyLoadAttempted: true, + keyLoadError: null, + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z", + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "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.", + ); + expect(console.log).not.toHaveBeenCalledWith( + "- Backup is present but not trusted for this device. Re-run 'openclaw matrix-js verify device '.", + ); + }); + + it("includes key load failure details in status output", async () => { + getMatrixVerificationStatusMock.mockResolvedValue({ + encryptionEnabled: true, + verified: true, + userId: "@bot:example.org", + deviceId: "DEVICE123", + backupVersion: "5256", + backup: { + serverVersion: "5256", + activeVersion: null, + trusted: true, + matchesDecryptionKey: false, + decryptionKeyCached: false, + keyLoadAttempted: true, + keyLoadError: "secret storage key is not available", + }, + recoveryKeyStored: true, + recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z", + pendingVerifications: 0, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "status"], { from: "user" }); + + expect(console.log).toHaveBeenCalledWith( + "Backup issue: backup decryption key could not be loaded from secret storage (secret storage key is not available)", + ); + }); + + it("prints backup health lines for verify backup status in verbose mode", async () => { + getMatrixRoomKeyBackupStatusMock.mockResolvedValue({ + serverVersion: "2", + activeVersion: null, + trusted: true, + matchesDecryptionKey: false, + decryptionKeyCached: false, + keyLoadAttempted: true, + keyLoadError: null, + }); + const program = buildProgram(); + + await program.parseAsync(["matrix-js", "verify", "backup", "status", "--verbose"], { + from: "user", + }); + + expect(console.log).toHaveBeenCalledWith("Backup server version: 2"); + expect(console.log).toHaveBeenCalledWith("Backup active on this device: no"); + expect(console.log).toHaveBeenCalledWith("Backup trusted by this device: yes"); + }); +}); diff --git a/extensions/matrix-js/src/cli.ts b/extensions/matrix-js/src/cli.ts new file mode 100644 index 00000000000..8d7fbca4ae8 --- /dev/null +++ b/extensions/matrix-js/src/cli.ts @@ -0,0 +1,871 @@ +import type { Command } from "commander"; +import { + formatZonedTimestamp, + normalizeAccountId, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/matrix-js"; +import { matrixPlugin } from "./channel.js"; +import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; +import { + bootstrapMatrixVerification, + getMatrixRoomKeyBackupStatus, + getMatrixVerificationStatus, + restoreMatrixRoomKeyBackup, + verifyMatrixRecoveryKey, +} from "./matrix/actions/verification.js"; +import { setMatrixSdkLogMode } from "./matrix/client/logging.js"; +import { updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { getMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +let matrixJsCliExitScheduled = false; + +function scheduleMatrixJsCliExit(): void { + if (matrixJsCliExitScheduled || process.env.VITEST) { + return; + } + matrixJsCliExitScheduled = true; + // matrix-js-sdk rust crypto can leave background async work alive after command completion. + setTimeout(() => { + process.exit(process.exitCode ?? 0); + }, 0); +} + +function markCliFailure(): void { + process.exitCode = 1; +} + +function toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function printJson(payload: unknown): void { + console.log(JSON.stringify(payload, null, 2)); +} + +function formatLocalTimestamp(value: string | null | undefined): string | null { + if (!value) { + return null; + } + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) { + return value; + } + return formatZonedTimestamp(parsed, { displaySeconds: true }) ?? value; +} + +function printTimestamp(label: string, value: string | null | undefined): void { + const formatted = formatLocalTimestamp(value); + if (formatted) { + console.log(`${label}: ${formatted}`); + } +} + +function printAccountLabel(accountId?: string): void { + console.log(`Account: ${normalizeAccountId(accountId)}`); +} + +function configureCliLogMode(verbose: boolean): void { + setMatrixSdkLogMode(verbose ? "default" : "quiet"); +} + +function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed)) { + throw new Error(`${fieldName} must be an integer`); + } + return parsed; +} + +type MatrixCliAccountAddResult = { + accountId: string; + configPath: string; + useEnv: boolean; + profile: { + attempted: boolean; + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + convertedAvatarFromHttp: boolean; + error?: string; + }; +}; + +async function addMatrixJsAccount(params: { + account?: string; + name?: string; + avatarUrl?: string; + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: string; + useEnv?: boolean; +}): Promise { + const runtime = getMatrixRuntime(); + const cfg = runtime.config.loadConfig() as CoreConfig; + const setup = matrixPlugin.setup; + if (!setup?.applyAccountConfig) { + throw new Error("Matrix-js account setup is unavailable."); + } + + const input: ChannelSetupInput & { avatarUrl?: string } = { + name: params.name, + avatarUrl: params.avatarUrl, + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + password: params.password, + deviceName: params.deviceName, + initialSyncLimit: parseOptionalInt(params.initialSyncLimit, "--initial-sync-limit"), + useEnv: params.useEnv === true, + }; + const accountId = + setup.resolveAccountId?.({ + cfg, + accountId: params.account, + input, + }) ?? normalizeAccountId(params.account?.trim() || params.name?.trim()); + + const validationError = setup.validateInput?.({ + cfg, + accountId, + input, + }); + if (validationError) { + throw new Error(validationError); + } + + const updated = setup.applyAccountConfig({ + cfg, + accountId, + input, + }) as CoreConfig; + await runtime.config.writeConfigFile(updated as never); + + const desiredDisplayName = input.name?.trim(); + const desiredAvatarUrl = input.avatarUrl?.trim(); + let profile: MatrixCliAccountAddResult["profile"] = { + attempted: false, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + }; + if (desiredDisplayName || desiredAvatarUrl) { + try { + const synced = await updateMatrixOwnProfile({ + accountId, + displayName: desiredDisplayName, + avatarUrl: desiredAvatarUrl, + }); + let resolvedAvatarUrl = synced.resolvedAvatarUrl; + if (synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl) { + const latestCfg = runtime.config.loadConfig() as CoreConfig; + const withAvatar = updateMatrixAccountConfig(latestCfg, accountId, { + avatarUrl: synced.resolvedAvatarUrl, + }); + await runtime.config.writeConfigFile(withAvatar as never); + resolvedAvatarUrl = synced.resolvedAvatarUrl; + } + profile = { + attempted: true, + displayNameUpdated: synced.displayNameUpdated, + avatarUpdated: synced.avatarUpdated, + resolvedAvatarUrl, + convertedAvatarFromHttp: synced.convertedAvatarFromHttp, + }; + } catch (err) { + profile = { + attempted: true, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + error: toErrorMessage(err), + }; + } + } + + return { + accountId, + configPath: `channels.matrix-js.accounts.${accountId}`, + useEnv: input.useEnv === true, + profile, + }; +} + +type MatrixCliProfileSetResult = { + accountId: string; + displayName: string | null; + avatarUrl: string | null; + profile: { + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + convertedAvatarFromHttp: boolean; + }; + configPath: string; +}; + +async function setMatrixJsProfile(params: { + account?: string; + name?: string; + avatarUrl?: string; +}): Promise { + const runtime = getMatrixRuntime(); + const cfg = runtime.config.loadConfig() as CoreConfig; + const accountId = normalizeAccountId(params.account); + const displayName = params.name?.trim() || null; + const avatarUrl = params.avatarUrl?.trim() || null; + if (!displayName && !avatarUrl) { + throw new Error("Provide --name and/or --avatar-url."); + } + + const synced = await updateMatrixOwnProfile({ + accountId, + displayName: displayName ?? undefined, + avatarUrl: avatarUrl ?? undefined, + }); + const persistedAvatarUrl = + synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl + ? synced.resolvedAvatarUrl + : avatarUrl; + const updated = updateMatrixAccountConfig(cfg, accountId, { + name: displayName ?? undefined, + avatarUrl: persistedAvatarUrl ?? undefined, + }); + await runtime.config.writeConfigFile(updated as never); + + return { + accountId, + displayName, + avatarUrl: persistedAvatarUrl ?? null, + profile: { + displayNameUpdated: synced.displayNameUpdated, + avatarUpdated: synced.avatarUpdated, + resolvedAvatarUrl: synced.resolvedAvatarUrl, + convertedAvatarFromHttp: synced.convertedAvatarFromHttp, + }, + configPath: `channels.matrix-js.accounts.${accountId}`, + }; +} + +type MatrixCliCommandConfig = { + verbose: boolean; + json: boolean; + run: () => Promise; + onText: (result: TResult, verbose: boolean) => void; + onJson?: (result: TResult) => unknown; + shouldFail?: (result: TResult) => boolean; + errorPrefix: string; + onJsonError?: (message: string) => unknown; +}; + +async function runMatrixCliCommand( + config: MatrixCliCommandConfig, +): Promise { + configureCliLogMode(config.verbose); + try { + const result = await config.run(); + if (config.json) { + printJson(config.onJson ? config.onJson(result) : result); + } else { + config.onText(result, config.verbose); + } + if (config.shouldFail?.(result)) { + markCliFailure(); + } + } catch (err) { + const message = toErrorMessage(err); + if (config.json) { + printJson(config.onJsonError ? config.onJsonError(message) : { error: message }); + } else { + console.error(`${config.errorPrefix}: ${message}`); + } + markCliFailure(); + } finally { + scheduleMatrixJsCliExit(); + } +} + +type MatrixCliBackupStatus = { + serverVersion: string | null; + activeVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + decryptionKeyCached: boolean | null; + keyLoadAttempted: boolean; + keyLoadError: string | null; +}; + +type MatrixCliVerificationStatus = { + encryptionEnabled: boolean; + verified: boolean; + userId: string | null; + deviceId: string | null; + backupVersion: string | null; + backup?: MatrixCliBackupStatus; + recoveryKeyStored: boolean; + recoveryKeyCreatedAt: string | null; + pendingVerifications: number; +}; + +function resolveBackupStatus(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): MatrixCliBackupStatus { + return { + serverVersion: status.backup?.serverVersion ?? status.backupVersion ?? null, + activeVersion: status.backup?.activeVersion ?? null, + trusted: status.backup?.trusted ?? null, + matchesDecryptionKey: status.backup?.matchesDecryptionKey ?? null, + decryptionKeyCached: status.backup?.decryptionKeyCached ?? null, + keyLoadAttempted: status.backup?.keyLoadAttempted ?? false, + keyLoadError: status.backup?.keyLoadError ?? null, + }; +} + +type MatrixCliBackupIssueCode = + | "missing-server-backup" + | "key-load-failed" + | "key-not-loaded" + | "key-mismatch" + | "untrusted-signature" + | "inactive" + | "indeterminate" + | "ok"; + +type MatrixCliBackupIssue = { + code: MatrixCliBackupIssueCode; + summary: string; + message: string | null; +}; + +function yesNoUnknown(value: boolean | null): string { + if (value === true) { + return "yes"; + } + if (value === false) { + return "no"; + } + return "unknown"; +} + +function printBackupStatus(backup: MatrixCliBackupStatus): void { + console.log(`Backup server version: ${backup.serverVersion ?? "none"}`); + console.log(`Backup active on this device: ${backup.activeVersion ?? "no"}`); + console.log(`Backup trusted by this device: ${yesNoUnknown(backup.trusted)}`); + console.log(`Backup matches local decryption key: ${yesNoUnknown(backup.matchesDecryptionKey)}`); + console.log(`Backup key cached locally: ${yesNoUnknown(backup.decryptionKeyCached)}`); + console.log(`Backup key load attempted: ${yesNoUnknown(backup.keyLoadAttempted)}`); + if (backup.keyLoadError) { + console.log(`Backup key load error: ${backup.keyLoadError}`); + } +} + +function printVerificationIdentity(status: { + userId: string | null; + deviceId: string | null; +}): void { + console.log(`User: ${status.userId ?? "unknown"}`); + console.log(`Device: ${status.deviceId ?? "unknown"}`); +} + +function printVerificationBackupSummary(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): void { + printBackupSummary(resolveBackupStatus(status)); +} + +function printVerificationBackupStatus(status: { + backupVersion: string | null; + backup?: MatrixCliBackupStatus; +}): void { + printBackupStatus(resolveBackupStatus(status)); +} + +function printVerificationGuidance(status: MatrixCliVerificationStatus): void { + printGuidance(buildVerificationGuidance(status)); +} + +function resolveBackupIssue(backup: MatrixCliBackupStatus): MatrixCliBackupIssue { + if (!backup.serverVersion) { + return { + code: "missing-server-backup", + summary: "missing on server", + message: "no room-key backup exists on the homeserver", + }; + } + if (backup.decryptionKeyCached === false) { + if (backup.keyLoadError) { + return { + code: "key-load-failed", + summary: "present but backup key unavailable on this device", + message: `backup decryption key could not be loaded from secret storage (${backup.keyLoadError})`, + }; + } + if (backup.keyLoadAttempted) { + return { + code: "key-not-loaded", + summary: "present but backup key unavailable on this device", + message: + "backup decryption key is not loaded on this device (secret storage did not return a key)", + }; + } + return { + code: "key-not-loaded", + summary: "present but backup key unavailable on this device", + message: "backup decryption key is not loaded on this device", + }; + } + if (backup.matchesDecryptionKey === false) { + return { + code: "key-mismatch", + summary: "present but backup key mismatch on this device", + message: "backup key mismatch (this device does not have the matching backup decryption key)", + }; + } + if (backup.trusted === false) { + return { + code: "untrusted-signature", + summary: "present but not trusted on this device", + message: "backup signature chain is not trusted by this device", + }; + } + if (!backup.activeVersion) { + return { + code: "inactive", + summary: "present on server but inactive on this device", + message: "backup exists but is not active on this device", + }; + } + if ( + backup.trusted === null || + backup.matchesDecryptionKey === null || + backup.decryptionKeyCached === null + ) { + return { + code: "indeterminate", + summary: "present but trust state unknown", + message: "backup trust state could not be fully determined", + }; + } + return { + code: "ok", + summary: "active and trusted on this device", + message: null, + }; +} + +function printBackupSummary(backup: MatrixCliBackupStatus): void { + const issue = resolveBackupIssue(backup); + console.log(`Backup: ${issue.summary}`); + if (backup.serverVersion) { + console.log(`Backup version: ${backup.serverVersion}`); + } +} + +function buildVerificationGuidance(status: MatrixCliVerificationStatus): string[] { + const backup = resolveBackupStatus(status); + const backupIssue = resolveBackupIssue(backup); + const nextSteps = new Set(); + if (!status.verified) { + nextSteps.add("Run 'openclaw matrix-js 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."); + } else if ( + backupIssue.code === "key-load-failed" || + backupIssue.code === "key-not-loaded" || + backupIssue.code === "inactive" + ) { + if (status.recoveryKeyStored) { + nextSteps.add( + "Backup key is not loaded on this device. Run 'openclaw matrix-js 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'.", + ); + } + } 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.", + ); + } else if (backupIssue.code === "untrusted-signature") { + nextSteps.add( + "Backup trust chain is not verified on this device. Re-run 'openclaw matrix-js verify device '.", + ); + } else if (backupIssue.code === "indeterminate") { + nextSteps.add( + "Run 'openclaw matrix-js verify status --verbose' to inspect backup trust diagnostics.", + ); + } + if (status.pendingVerifications > 0) { + nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`); + } + return Array.from(nextSteps); +} + +function printGuidance(lines: string[]): void { + if (lines.length === 0) { + return; + } + console.log("Next steps:"); + for (const line of lines) { + console.log(`- ${line}`); + } +} + +function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void { + console.log(`Verified: ${status.verified ? "yes" : "no"}`); + const backup = resolveBackupStatus(status); + const backupIssue = resolveBackupIssue(backup); + printVerificationBackupSummary(status); + if (backupIssue.message) { + console.log(`Backup issue: ${backupIssue.message}`); + } + if (verbose) { + console.log("Diagnostics:"); + printVerificationIdentity(status); + printVerificationBackupStatus(status); + console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); + printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt); + console.log(`Pending verifications: ${status.pendingVerifications}`); + } else { + console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); + } + printVerificationGuidance(status); +} + +export function registerMatrixJsCli(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"); + + const account = root.command("account").description("Manage matrix-js channel accounts"); + + account + .command("add") + .description("Add or update a matrix-js account (wrapper around channel setup)") + .option("--account ", "Account ID (default: normalized --name, else default)") + .option("--name ", "Optional display name for this account") + .option("--avatar-url ", "Optional Matrix avatar URL (mxc:// or http(s) URL)") + .option("--homeserver ", "Matrix homeserver URL") + .option("--user-id ", "Matrix user ID") + .option("--access-token ", "Matrix access token") + .option("--password ", "Matrix password") + .option("--device-name ", "Matrix device display name") + .option("--initial-sync-limit ", "Matrix initial sync limit") + .option( + "--use-env", + "Use MATRIX_* env vars (or MATRIX__* for non-default accounts)", + ) + .option("--verbose", "Show setup details") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + name?: string; + avatarUrl?: string; + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: string; + useEnv?: boolean; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await addMatrixJsAccount({ + account: options.account, + name: options.name, + avatarUrl: options.avatarUrl, + homeserver: options.homeserver, + userId: options.userId, + accessToken: options.accessToken, + password: options.password, + deviceName: options.deviceName, + initialSyncLimit: options.initialSyncLimit, + useEnv: options.useEnv === true, + }), + onText: (result) => { + console.log(`Saved matrix-js account: ${result.accountId}`); + console.log(`Config path: ${result.configPath}`); + console.log( + `Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX__* env vars" : "inline config"}`, + ); + if (result.profile.attempted) { + if (result.profile.error) { + console.error(`Profile sync warning: ${result.profile.error}`); + } else { + console.log( + `Profile sync: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, + ); + if (result.profile.convertedAvatarFromHttp && result.profile.resolvedAvatarUrl) { + console.log(`Avatar converted and saved as: ${result.profile.resolvedAvatarUrl}`); + } + } + } + const bindHint = `openclaw agents bind --agent --bind matrix-js:${result.accountId}`; + console.log(`Bind this account to an agent: ${bindHint}`); + }, + errorPrefix: "Account setup failed", + }); + }, + ); + + const profile = root.command("profile").description("Manage Matrix-js bot profile"); + + profile + .command("set") + .description("Update Matrix profile display name and/or avatar") + .option("--account ", "Account ID (for multi-account setups)") + .option("--name ", "Profile display name") + .option("--avatar-url ", "Profile avatar URL (mxc:// or http(s) URL)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + name?: string; + avatarUrl?: string; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await setMatrixJsProfile({ + account: options.account, + name: options.name, + avatarUrl: options.avatarUrl, + }), + onText: (result) => { + printAccountLabel(result.accountId); + console.log(`Config path: ${result.configPath}`); + console.log( + `Profile update: name ${result.profile.displayNameUpdated ? "updated" : "unchanged"}, avatar ${result.profile.avatarUpdated ? "updated" : "unchanged"}`, + ); + if (result.profile.convertedAvatarFromHttp && result.avatarUrl) { + console.log(`Avatar converted and saved as: ${result.avatarUrl}`); + } + }, + errorPrefix: "Profile update failed", + }); + }, + ); + + const verify = root.command("verify").description("Device verification for Matrix E2EE"); + + verify + .command("status") + .description("Check Matrix-js device verification status") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--include-recovery-key", "Include stored recovery key in output") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + verbose?: boolean; + includeRecoveryKey?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await getMatrixVerificationStatus({ + accountId: options.account, + includeRecoveryKey: options.includeRecoveryKey === true, + }), + onText: (status, verbose) => { + printAccountLabel(options.account); + printVerificationStatus(status, verbose); + }, + errorPrefix: "Error", + }); + }, + ); + + const backup = verify.command("backup").description("Matrix room-key backup health and restore"); + + backup + .command("status") + .description("Show Matrix room-key backup status for this device") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action(async (options: { account?: string; verbose?: boolean; json?: boolean }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await getMatrixRoomKeyBackupStatus({ accountId: options.account }), + onText: (status, verbose) => { + printAccountLabel(options.account); + printBackupSummary(status); + if (verbose) { + printBackupStatus(status); + } + }, + errorPrefix: "Backup status failed", + }); + }); + + backup + .command("restore") + .description("Restore encrypted room keys from server backup") + .option("--account ", "Account ID (for multi-account setups)") + .option("--recovery-key ", "Optional recovery key to load before restoring") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + recoveryKey?: string; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await restoreMatrixRoomKeyBackup({ + accountId: options.account, + recoveryKey: options.recoveryKey, + }), + onText: (result, verbose) => { + printAccountLabel(options.account); + console.log(`Restore success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Backup version: ${result.backupVersion ?? "none"}`); + console.log(`Imported keys: ${result.imported}/${result.total}`); + printBackupSummary(result.backup); + if (verbose) { + console.log( + `Loaded key from secret storage: ${result.loadedFromSecretStorage ? "yes" : "no"}`, + ); + printTimestamp("Restored at", result.restoredAt); + printBackupStatus(result.backup); + } + }, + shouldFail: (result) => !result.success, + errorPrefix: "Backup restore failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + verify + .command("bootstrap") + .description("Bootstrap Matrix-js cross-signing and device verification state") + .option("--account ", "Account ID (for multi-account setups)") + .option("--recovery-key ", "Recovery key to apply before bootstrap") + .option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (options: { + account?: string; + recoveryKey?: string; + forceResetCrossSigning?: boolean; + verbose?: boolean; + json?: boolean; + }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => + await bootstrapMatrixVerification({ + accountId: options.account, + recoveryKey: options.recoveryKey, + forceResetCrossSigning: options.forceResetCrossSigning === true, + }), + onText: (result, verbose) => { + printAccountLabel(options.account); + console.log(`Bootstrap success: ${result.success ? "yes" : "no"}`); + if (result.error) { + console.log(`Error: ${result.error}`); + } + console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`); + printVerificationIdentity(result.verification); + if (verbose) { + console.log( + `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`, + ); + printVerificationBackupStatus(result.verification); + printTimestamp("Recovery key created at", result.verification.recoveryKeyCreatedAt); + console.log(`Pending verifications: ${result.pendingVerifications}`); + } else { + console.log( + `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"}`, + ); + printVerificationBackupSummary(result.verification); + } + printVerificationGuidance({ + ...result.verification, + pendingVerifications: result.pendingVerifications, + }); + }, + shouldFail: (result) => !result.success, + errorPrefix: "Verification bootstrap failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); + + verify + .command("device ") + .description("Verify device using a Matrix recovery key") + .option("--account ", "Account ID (for multi-account setups)") + .option("--verbose", "Show detailed diagnostics") + .option("--json", "Output as JSON") + .action( + async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => { + await runMatrixCliCommand({ + verbose: options.verbose === true, + json: options.json === true, + run: async () => await verifyMatrixRecoveryKey(key, { accountId: options.account }), + onText: (result, verbose) => { + printAccountLabel(options.account); + if (!result.success) { + console.error(`Verification failed: ${result.error ?? "unknown error"}`); + return; + } + console.log("Device verification completed successfully."); + printVerificationIdentity(result); + printVerificationBackupSummary(result); + if (verbose) { + printVerificationBackupStatus(result); + printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt); + printTimestamp("Verified at", result.verifiedAt); + } + printVerificationGuidance({ + ...result, + pendingVerifications: 0, + }); + }, + shouldFail: (result) => !result.success, + errorPrefix: "Verification failed", + onJsonError: (message) => ({ success: false, error: message }), + }); + }, + ); +} diff --git a/extensions/matrix-js/src/config-migration.ts b/extensions/matrix-js/src/config-migration.ts new file mode 100644 index 00000000000..a5989024e83 --- /dev/null +++ b/extensions/matrix-js/src/config-migration.ts @@ -0,0 +1,133 @@ +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" + | "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", + "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 new file mode 100644 index 00000000000..1876847b939 --- /dev/null +++ b/extensions/matrix-js/src/config-schema.ts @@ -0,0 +1,66 @@ +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 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(), + 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 new file mode 100644 index 00000000000..405624ccaa9 --- /dev/null +++ b/extensions/matrix-js/src/directory-live.test.ts @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000000..0fefdc45f2c --- /dev/null +++ b/extensions/matrix-js/src/directory-live.ts @@ -0,0 +1,208 @@ +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 new file mode 100644 index 00000000000..f6d3540f5d0 --- /dev/null +++ b/extensions/matrix-js/src/group-mentions.ts @@ -0,0 +1,52 @@ +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/account-config.ts b/extensions/matrix-js/src/matrix/account-config.ts new file mode 100644 index 00000000000..e7ef49807ea --- /dev/null +++ b/extensions/matrix-js/src/matrix/account-config.ts @@ -0,0 +1,37 @@ +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"] ?? {}; +} + +export function resolveMatrixAccountsMap( + cfg: CoreConfig, +): Readonly> { + const accounts = resolveMatrixBaseConfig(cfg).accounts; + if (!accounts || typeof accounts !== "object") { + return {}; + } + return accounts; +} + +export function findMatrixAccountConfig( + cfg: CoreConfig, + accountId: string, +): MatrixAccountConfig | undefined { + const accounts = resolveMatrixAccountsMap(cfg); + if (accounts[accountId] && typeof accounts[accountId] === "object") { + return accounts[accountId]; + } + const normalized = normalizeAccountId(accountId); + for (const key of Object.keys(accounts)) { + if (normalizeAccountId(key) === normalized) { + const candidate = accounts[key]; + if (candidate && typeof candidate === "object") { + return candidate; + } + return undefined; + } + } + return undefined; +} diff --git a/extensions/matrix-js/src/matrix/accounts.test.ts b/extensions/matrix-js/src/matrix/accounts.test.ts new file mode 100644 index 00000000000..f61b123fc32 --- /dev/null +++ b/extensions/matrix-js/src/matrix/accounts.test.ts @@ -0,0 +1,82 @@ +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 new file mode 100644 index 00000000000..a25dde78965 --- /dev/null +++ b/extensions/matrix-js/src/matrix/accounts.ts @@ -0,0 +1,127 @@ +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 new file mode 100644 index 00000000000..2ee53c7c16b --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions.ts @@ -0,0 +1,35 @@ +export type { + MatrixActionClientOpts, + MatrixMessageSummary, + MatrixReactionSummary, +} from "./actions/types.js"; +export { + sendMatrixMessage, + editMatrixMessage, + deleteMatrixMessage, + readMatrixMessages, +} from "./actions/messages.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-js/src/matrix/actions/client.test.ts new file mode 100644 index 00000000000..8e25cad8fbc --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/client.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; + +const loadConfigMock = vi.fn(() => ({})); +const getActiveMatrixClientMock = vi.fn(); +const createMatrixClientMock = vi.fn(); +const isBunRuntimeMock = vi.fn(() => false); +const resolveMatrixAuthMock = vi.fn(); + +vi.mock("../../runtime.js", () => ({ + getMatrixRuntime: () => ({ + config: { + loadConfig: loadConfigMock, + }, + }), +})); + +vi.mock("../active-client.js", () => ({ + getActiveMatrixClient: getActiveMatrixClientMock, +})); + +vi.mock("../client.js", () => ({ + createMatrixClient: createMatrixClientMock, + isBunRuntime: () => isBunRuntimeMock(), + resolveMatrixAuth: resolveMatrixAuthMock, +})); + +let resolveActionClient: typeof import("./client.js").resolveActionClient; + +function createMockMatrixClient(): MatrixClient { + return { + prepareForOneOff: vi.fn(async () => undefined), + } as unknown as MatrixClient; +} + +describe("resolveActionClient", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + getActiveMatrixClientMock.mockReturnValue(null); + isBunRuntimeMock.mockReturnValue(false); + resolveMatrixAuthMock.mockResolvedValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + password: undefined, + deviceId: "DEVICE123", + encryption: false, + }); + createMatrixClientMock.mockResolvedValue(createMockMatrixClient()); + + ({ resolveActionClient } = await import("./client.js")); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { + vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); + + const result = await resolveActionClient({ accountId: "default" }); + + expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); + expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); + expect(createMatrixClientMock).toHaveBeenCalledTimes(1); + expect(createMatrixClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + autoBootstrapCrypto: false, + }), + ); + const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; + expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); + expect(result.stopOnDone).toBe(true); + }); + + it("reuses active monitor client when available", async () => { + const activeClient = createMockMatrixClient(); + getActiveMatrixClientMock.mockReturnValue(activeClient); + + const result = await resolveActionClient({ accountId: "default" }); + + expect(result).toEqual({ client: activeClient, stopOnDone: false }); + expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); + expect(createMatrixClientMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/actions/client.ts b/extensions/matrix-js/src/matrix/actions/client.ts new file mode 100644 index 00000000000..a3981be0520 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/client.ts @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000000..d6c85ab7fae --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/limits.test.ts @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000000..f18d9e2c059 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/limits.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000000..3fcf1cd43d4 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/messages.ts @@ -0,0 +1,111 @@ +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, + 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 new file mode 100644 index 00000000000..5b621de5d63 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/pins.test.ts @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000000..ca5ca4a8524 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/pins.ts @@ -0,0 +1,79 @@ +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/profile.ts b/extensions/matrix-js/src/matrix/actions/profile.ts new file mode 100644 index 00000000000..1d3f8c924db --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/profile.ts @@ -0,0 +1,29 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import { syncMatrixOwnProfile, type MatrixProfileSyncResult } from "../profile.js"; +import { withResolvedActionClient } from "./client.js"; +import type { MatrixActionClientOpts } from "./types.js"; + +export async function updateMatrixOwnProfile( + opts: MatrixActionClientOpts & { + displayName?: string; + avatarUrl?: string; + } = {}, +): Promise { + const displayName = opts.displayName?.trim(); + const avatarUrl = opts.avatarUrl?.trim(); + const runtime = getMatrixRuntime(); + return await withResolvedActionClient( + opts, + async (client) => { + const userId = await client.getUserId(); + return await syncMatrixOwnProfile({ + client, + userId, + displayName: displayName || undefined, + avatarUrl: avatarUrl || undefined, + loadAvatarFromUrl: async (url, maxBytes) => await runtime.media.loadWebMedia(url, maxBytes), + }); + }, + "persist", + ); +} diff --git a/extensions/matrix-js/src/matrix/actions/reactions.test.ts b/extensions/matrix-js/src/matrix/actions/reactions.test.ts new file mode 100644 index 00000000000..9f5e13f9a12 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/reactions.test.ts @@ -0,0 +1,109 @@ +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(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/actions/reactions.ts b/extensions/matrix-js/src/matrix/actions/reactions.ts new file mode 100644 index 00000000000..18b21d3de30 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/reactions.ts @@ -0,0 +1,86 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { withResolvedActionClient } from "./client.js"; +import { + EventType, + RelationType, + type MatrixActionClientOpts, + type MatrixRawEvent, + type MatrixReactionSummary, + type ReactionEventContent, +} from "./types.js"; + +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 = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 100; + // Relations are queried via the low-level endpoint for compatibility. + const res = (await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit }, + )) as { chunk: MatrixRawEvent[] }; + const summaries = new Map(); + for (const event of res.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()); + }); +} + +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 res = (await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit: 200 }, + )) as { chunk: MatrixRawEvent[] }; + const userId = await client.getUserId(); + if (!userId) { + return { removed: 0 }; + } + const targetEmoji = opts.emoji?.trim(); + const toRemove = res.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)); + 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 new file mode 100644 index 00000000000..8180a3dc253 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/room.ts @@ -0,0 +1,72 @@ +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 new file mode 100644 index 00000000000..5fd81401183 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/summary.ts @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000000..f6f94481c3a --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/types.ts @@ -0,0 +1,75 @@ +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: "m.annotation", +} as const; + +export const EventType = { + RoomMessage: "m.room.message", + RoomPinnedEvents: "m.room.pinned_events", + RoomTopic: "m.room.topic", + Reaction: "m.reaction", +} 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 = { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +}; + +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 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-js/src/matrix/actions/verification.ts new file mode 100644 index 00000000000..f22185194e8 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/verification.ts @@ -0,0 +1,285 @@ +import { withResolvedActionClient } from "./client.js"; +import type { MatrixActionClientOpts } from "./types.js"; + +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)", + ); + } + return client.crypto; +} + +function resolveVerificationId(input: string): string { + const normalized = input.trim(); + if (!normalized) { + throw new Error("Matrix verification request id is required"); + } + return normalized; +} + +export async function listMatrixVerifications(opts: MatrixActionClientOpts = {}) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.listVerifications(); + }, + "persist", + ); +} + +export async function requestMatrixVerification( + params: MatrixActionClientOpts & { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + } = {}, +) { + return await withResolvedActionClient( + params, + async (client) => { + const crypto = requireCrypto(client); + const ownUser = params.ownUser ?? (!params.userId && !params.deviceId && !params.roomId); + return await crypto.requestVerification({ + ownUser, + userId: params.userId?.trim() || undefined, + deviceId: params.deviceId?.trim() || undefined, + roomId: params.roomId?.trim() || undefined, + }); + }, + "persist", + ); +} + +export async function acceptMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.acceptVerification(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function cancelMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.cancelVerification(resolveVerificationId(requestId), { + reason: opts.reason?.trim() || undefined, + code: opts.code?.trim() || undefined, + }); + }, + "persist", + ); +} + +export async function startMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { method?: "sas" } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); + }, + "persist", + ); +} + +export async function generateMatrixVerificationQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.generateVerificationQr(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function scanMatrixVerificationQr( + requestId: string, + qrDataBase64: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + const payload = qrDataBase64.trim(); + if (!payload) { + throw new Error("Matrix QR data is required"); + } + return await crypto.scanVerificationQr(resolveVerificationId(requestId), payload); + }, + "persist", + ); +} + +export async function getMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.getVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function confirmMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function mismatchMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function confirmMatrixVerificationReciprocateQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); + }, + "persist", + ); +} + +export async function getMatrixEncryptionStatus( + opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const crypto = requireCrypto(client); + const recoveryKey = await crypto.getRecoveryKey(); + return { + encryptionEnabled: true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + ...(opts.includeRecoveryKey ? { recoveryKey: recoveryKey?.encodedPrivateKey ?? null } : {}), + pendingVerifications: (await crypto.listVerifications()).length, + }; + }, + "persist", + ); +} + +export async function getMatrixVerificationStatus( + opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => { + const status = await client.getOwnDeviceVerificationStatus(); + const payload = { + ...status, + pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0, + }; + if (!opts.includeRecoveryKey) { + return payload; + } + const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null; + return { + ...payload, + recoveryKey: recoveryKey?.encodedPrivateKey ?? null, + }; + }, + "persist", + ); +} + +export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) { + return await withResolvedActionClient( + opts, + async (client) => await client.getRoomKeyBackupStatus(), + "persist", + ); +} + +export async function verifyMatrixRecoveryKey( + recoveryKey: string, + opts: MatrixActionClientOpts = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => await client.verifyWithRecoveryKey(recoveryKey), + "persist", + ); +} + +export async function restoreMatrixRoomKeyBackup( + opts: MatrixActionClientOpts & { + recoveryKey?: string; + } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => + await client.restoreRoomKeyBackup({ + recoveryKey: opts.recoveryKey?.trim() || undefined, + }), + "persist", + ); +} + +export async function bootstrapMatrixVerification( + opts: MatrixActionClientOpts & { + recoveryKey?: string; + forceResetCrossSigning?: boolean; + } = {}, +) { + return await withResolvedActionClient( + opts, + async (client) => + await client.bootstrapOwnDeviceVerification({ + recoveryKey: opts.recoveryKey?.trim() || undefined, + forceResetCrossSigning: opts.forceResetCrossSigning === true, + }), + "persist", + ); +} diff --git a/extensions/matrix-js/src/matrix/active-client.ts b/extensions/matrix-js/src/matrix/active-client.ts new file mode 100644 index 00000000000..990acb6f116 --- /dev/null +++ b/extensions/matrix-js/src/matrix/active-client.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000000..66512291945 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client-bootstrap.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000000..d01974626ac --- /dev/null +++ b/extensions/matrix-js/src/matrix/client.test.ts @@ -0,0 +1,314 @@ +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 new file mode 100644 index 00000000000..82fe95d0fed --- /dev/null +++ b/extensions/matrix-js/src/matrix/client.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 00000000000..975ec14298d --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/config.ts @@ -0,0 +1,362 @@ +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 new file mode 100644 index 00000000000..3a1d996233f --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/create-client.ts @@ -0,0 +1,58 @@ +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, 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 new file mode 100644 index 00000000000..be50656497f --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/logging.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 00000000000..4995eaf8d5c --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/runtime.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000000..72b708f3c93 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/shared.test.ts @@ -0,0 +1,112 @@ +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 new file mode 100644 index 00000000000..fd7c76995f1 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/shared.ts @@ -0,0 +1,193 @@ +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 new file mode 100644 index 00000000000..b09b6be2695 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/storage.ts @@ -0,0 +1,134 @@ +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"), + }; +} + +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, + "credentials", + "matrix-js", + "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; + env?: NodeJS.ProcessEnv; +}): void { + const legacy = resolveLegacyStoragePaths(params.env); + const hasLegacyStorage = fs.existsSync(legacy.storagePath); + const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); + const hasNewStorage = + fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath); + + if (!hasLegacyStorage && !hasLegacyCrypto) { + return; + } + if (hasNewStorage) { + 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 new file mode 100644 index 00000000000..4a6bac48a40 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/types.ts @@ -0,0 +1,39 @@ +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/config-update.test.ts b/extensions/matrix-js/src/matrix/config-update.test.ts new file mode 100644 index 00000000000..3be4a6dac89 --- /dev/null +++ b/extensions/matrix-js/src/matrix/config-update.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { CoreConfig } from "../types.js"; +import { updateMatrixAccountConfig } from "./config-update.js"; + +describe("updateMatrixAccountConfig", () => { + it("supports explicit null clears and boolean false values", () => { + const cfg = { + channels: { + "matrix-js": { + accounts: { + default: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "old-token", + password: "old-password", + encryption: true, + }, + }, + }, + }, + } as CoreConfig; + + const updated = updateMatrixAccountConfig(cfg, "default", { + accessToken: "new-token", + password: null, + userId: null, + encryption: false, + }); + + expect(updated.channels?.["matrix-js"]?.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(); + }); + + it("normalizes account id and defaults account enabled=true", () => { + const updated = updateMatrixAccountConfig({} as CoreConfig, "Main Bot", { + name: "Main Bot", + homeserver: "https://matrix.example.org", + }); + + expect(updated.channels?.["matrix-js"]?.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-js/src/matrix/config-update.ts new file mode 100644 index 00000000000..1082eda5a6b --- /dev/null +++ b/extensions/matrix-js/src/matrix/config-update.ts @@ -0,0 +1,102 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/matrix-js"; +import type { CoreConfig, MatrixConfig } from "../types.js"; + +export type MatrixAccountPatch = { + name?: string | null; + enabled?: boolean; + homeserver?: string | null; + userId?: string | null; + accessToken?: string | null; + password?: string | null; + deviceName?: string | null; + avatarUrl?: string | null; + encryption?: boolean | null; + initialSyncLimit?: number | null; +}; + +function applyNullableStringField( + target: Record, + key: keyof MatrixAccountPatch, + value: string | null | undefined, +): void { + if (value === undefined) { + return; + } + if (value === null) { + delete target[key]; + return; + } + const trimmed = value.trim(); + if (!trimmed) { + delete target[key]; + return; + } + target[key] = trimmed; +} + +export function updateMatrixAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: MatrixAccountPatch, +): CoreConfig { + const matrix = cfg.channels?.["matrix-js"] ?? {}; + const normalizedAccountId = normalizeAccountId(accountId); + const existingAccount = (matrix.accounts?.[normalizedAccountId] ?? {}) as MatrixConfig; + const nextAccount: Record = { ...existingAccount }; + + if (patch.name !== undefined) { + if (patch.name === null) { + delete nextAccount.name; + } else { + const trimmed = patch.name.trim(); + if (trimmed) { + nextAccount.name = trimmed; + } else { + delete nextAccount.name; + } + } + } + if (typeof patch.enabled === "boolean") { + nextAccount.enabled = patch.enabled; + } else if (typeof nextAccount.enabled !== "boolean") { + nextAccount.enabled = true; + } + + applyNullableStringField(nextAccount, "homeserver", patch.homeserver); + applyNullableStringField(nextAccount, "userId", patch.userId); + applyNullableStringField(nextAccount, "accessToken", patch.accessToken); + applyNullableStringField(nextAccount, "password", patch.password); + applyNullableStringField(nextAccount, "deviceName", patch.deviceName); + applyNullableStringField(nextAccount, "avatarUrl", patch.avatarUrl); + + if (patch.initialSyncLimit !== undefined) { + if (patch.initialSyncLimit === null) { + delete nextAccount.initialSyncLimit; + } else { + nextAccount.initialSyncLimit = Math.max(0, Math.floor(patch.initialSyncLimit)); + } + } + + if (patch.encryption !== undefined) { + if (patch.encryption === null) { + delete nextAccount.encryption; + } else { + nextAccount.encryption = patch.encryption; + } + } + + return { + ...cfg, + channels: { + ...cfg.channels, + "matrix-js": { + ...matrix, + enabled: true, + accounts: { + ...matrix.accounts, + [normalizedAccountId]: nextAccount as MatrixConfig, + }, + }, + }, + }; +} diff --git a/extensions/matrix-js/src/matrix/credentials.test.ts b/extensions/matrix-js/src/matrix/credentials.test.ts new file mode 100644 index 00000000000..840b755be09 --- /dev/null +++ b/extensions/matrix-js/src/matrix/credentials.test.ts @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../runtime.js"; +import { + loadMatrixCredentials, + resolveMatrixCredentialsPath, + saveMatrixCredentials, + touchMatrixCredentials, +} from "./credentials.js"; + +describe("matrix credentials storage", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function setupStateDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-creds-")); + tempDirs.push(dir); + setMatrixRuntime({ + state: { + resolveStateDir: () => dir, + }, + } as never); + return dir; + } + + it("writes credentials atomically with secure file permissions", async () => { + setupStateDir(); + await saveMatrixCredentials( + { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token", + deviceId: "DEVICE123", + }, + {}, + "ops", + ); + + const credPath = resolveMatrixCredentialsPath({}, "ops"); + expect(fs.existsSync(credPath)).toBe(true); + const mode = fs.statSync(credPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("touch updates lastUsedAt while preserving createdAt", async () => { + setupStateDir(); + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + await saveMatrixCredentials( + { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token", + }, + {}, + "default", + ); + const initial = loadMatrixCredentials({}, "default"); + expect(initial).not.toBeNull(); + + vi.setSystemTime(new Date("2026-03-01T10:05:00.000Z")); + await touchMatrixCredentials({}, "default"); + const touched = loadMatrixCredentials({}, "default"); + expect(touched).not.toBeNull(); + + expect(touched?.createdAt).toBe(initial?.createdAt); + expect(touched?.lastUsedAt).toBe("2026-03-01T10:05:00.000Z"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extensions/matrix-js/src/matrix/credentials.ts b/extensions/matrix-js/src/matrix/credentials.ts new file mode 100644 index 00000000000..b7c848457f2 --- /dev/null +++ b/extensions/matrix-js/src/matrix/credentials.ts @@ -0,0 +1,123 @@ +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-js"); +} + +export function resolveMatrixCredentialsPath( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): string { + const dir = resolveMatrixCredentialsDir(env); + return path.join(dir, credentialsFilename(accountId)); +} + +export function loadMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): MatrixStoredCredentials | null { + 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 { + 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 { + 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); + try { + if (fs.existsSync(credPath)) { + fs.unlinkSync(credPath); + } + } 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 new file mode 100644 index 00000000000..6585c7420d2 --- /dev/null +++ b/extensions/matrix-js/src/matrix/deps.ts @@ -0,0 +1,157 @@ +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 new file mode 100644 index 00000000000..4538c2792e2 --- /dev/null +++ b/extensions/matrix-js/src/matrix/format.test.ts @@ -0,0 +1,33 @@ +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 new file mode 100644 index 00000000000..7cd75d8a1ae --- /dev/null +++ b/extensions/matrix-js/src/matrix/index.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000000..d91ef71ceeb --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/allowlist.test.ts @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000000..eabb263baca --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/allowlist.ts @@ -0,0 +1,103 @@ +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.test.ts b/extensions/matrix-js/src/matrix/monitor/auto-join.test.ts new file mode 100644 index 00000000000..367bb9de9c1 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/auto-join.test.ts @@ -0,0 +1,127 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk/matrix-js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; +import { registerMatrixAutoJoin } from "./auto-join.js"; + +type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise; + +function createClientStub() { + let inviteHandler: InviteHandler | null = null; + const client = { + on: vi.fn((eventName: string, listener: unknown) => { + if (eventName === "room.invite") { + inviteHandler = listener as InviteHandler; + } + return client; + }), + joinRoom: vi.fn(async () => {}), + getRoomStateEvent: vi.fn(async () => ({})), + } as unknown as import("../sdk.js").MatrixClient; + + return { + client, + getInviteHandler: () => inviteHandler, + joinRoom: (client as unknown as { joinRoom: ReturnType }).joinRoom, + getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType }) + .getRoomStateEvent, + }; +} + +describe("registerMatrixAutoJoin", () => { + beforeEach(() => { + setMatrixRuntime({ + logging: { + shouldLogVerbose: () => false, + }, + } as unknown as PluginRuntime); + }); + + it("joins all invites when autoJoin=always", async () => { + const { client, getInviteHandler, joinRoom } = createClientStub(); + const cfg: CoreConfig = { + channels: { + "matrix-js": { + autoJoin: "always", + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + }); + + const inviteHandler = getInviteHandler(); + expect(inviteHandler).toBeTruthy(); + await inviteHandler!("!room:example.org", {}); + + expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); + }); + + it("ignores invites outside allowlist when autoJoin=allowlist", async () => { + const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub(); + getRoomStateEvent.mockResolvedValue({ + alias: "#other:example.org", + alt_aliases: ["#else:example.org"], + }); + const cfg: CoreConfig = { + channels: { + "matrix-js": { + autoJoin: "allowlist", + autoJoinAllowlist: ["#allowed:example.org"], + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + }); + + const inviteHandler = getInviteHandler(); + expect(inviteHandler).toBeTruthy(); + await inviteHandler!("!room:example.org", {}); + + expect(joinRoom).not.toHaveBeenCalled(); + }); + + it("joins invite when alias matches allowlist", async () => { + const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub(); + getRoomStateEvent.mockResolvedValue({ + alias: "#allowed:example.org", + alt_aliases: ["#backup:example.org"], + }); + const cfg: CoreConfig = { + channels: { + "matrix-js": { + autoJoin: "allowlist", + autoJoinAllowlist: [" #allowed:example.org "], + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk/matrix-js").RuntimeEnv, + }); + + const inviteHandler = getInviteHandler(); + expect(inviteHandler).toBeTruthy(); + await inviteHandler!("!room:example.org", {}); + + expect(joinRoom).toHaveBeenCalledWith("!room:example.org"); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/auto-join.ts b/extensions/matrix-js/src/matrix/monitor/auto-join.ts new file mode 100644 index 00000000000..6174d08d51f --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/auto-join.ts @@ -0,0 +1,75 @@ +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 new file mode 100644 index 00000000000..de767e1db08 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/direct.ts @@ -0,0 +1,104 @@ +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 new file mode 100644 index 00000000000..cc9c8290b94 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/events.test.ts @@ -0,0 +1,208 @@ +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("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 new file mode 100644 index 00000000000..5147deb1ec3 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/events.ts @@ -0,0 +1,396 @@ +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"}`, + ); + } + + routeVerificationEvent(roomId, event); + }); +} diff --git a/extensions/matrix-js/src/matrix/monitor/handler.test.ts b/extensions/matrix-js/src/matrix/monitor/handler.test.ts new file mode 100644 index 00000000000..7eb1e3712ab --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/handler.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { EventType, type MatrixRawEvent } from "./types.js"; + +const sendMessageMatrixMock = vi.hoisted(() => + vi.fn(async (..._args: unknown[]) => ({ messageId: "evt", roomId: "!room" })), +); + +vi.mock("../send.js", () => ({ + reactMatrixMessage: vi.fn(async () => {}), + sendMessageMatrix: sendMessageMatrixMock, + sendReadReceiptMatrix: vi.fn(async () => {}), + sendTypingMatrix: vi.fn(async () => {}), +})); + +describe("matrix monitor handler pairing account scope", () => { + it("caches account-scoped allowFrom store reads on hot path", async () => { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + sendMessageMatrixMock.mockClear(); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + } as never, + core: { + channel: { + pairing: { + readAllowFromStore, + upsertPairingRequest, + buildPairingReply: () => "pairing", + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: {} as never, + logger: { + info: () => {}, + warn: () => {}, + } as never, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "pairing", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => true, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async () => "sender", + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: "$event1", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + "m.mentions": { room: true }, + }, + } as MatrixRawEvent); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: "$event2", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello again", + "m.mentions": { room: true }, + }, + } as MatrixRawEvent); + + expect(readAllowFromStore).toHaveBeenCalledTimes(1); + }); + + it("sends pairing reminders for pending requests with cooldown", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-01T10:00:00.000Z")); + try { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + sendMessageMatrixMock.mockClear(); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + } as never, + core: { + channel: { + pairing: { + readAllowFromStore, + upsertPairingRequest, + buildPairingReply: () => "Pairing code: ABCDEFGH", + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: {} as never, + logger: { + info: () => {}, + warn: () => {}, + } as never, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "pairing", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => true, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async () => "sender", + }); + + const makeEvent = (id: string): MatrixRawEvent => + ({ + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: id, + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + "m.mentions": { room: true }, + }, + }) as MatrixRawEvent; + + await handler("!room:example.org", makeEvent("$event1")); + await handler("!room:example.org", makeEvent("$event2")); + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1); + expect(String(sendMessageMatrixMock.mock.calls[0]?.[1] ?? "")).toContain( + "Pairing request is still pending approval.", + ); + + await vi.advanceTimersByTimeAsync(5 * 60_000 + 1); + await handler("!room:example.org", makeEvent("$event3")); + expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("uses account-scoped pairing store reads and upserts for dm pairing", async () => { + const readAllowFromStore = vi.fn(async () => [] as string[]); + const upsertPairingRequest = vi.fn(async () => ({ code: "ABCDEFGH", created: false })); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + } as never, + core: { + channel: { + pairing: { + readAllowFromStore, + upsertPairingRequest, + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: {} as never, + logger: { + info: () => {}, + warn: () => {}, + } as never, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "pairing", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => true, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async () => "sender", + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: "$event1", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + "m.mentions": { room: true }, + }, + } as MatrixRawEvent); + + expect(readAllowFromStore).toHaveBeenCalledWith({ + channel: "matrix-js", + env: process.env, + accountId: "ops", + }); + expect(upsertPairingRequest).toHaveBeenCalledWith({ + channel: "matrix-js", + id: "@user:example.org", + accountId: "ops", + meta: { name: "sender" }, + }); + }); + + it("passes accountId into route resolution for inbound dm messages", async () => { + const resolveAgentRoute = vi.fn(() => ({ + agentId: "ops", + channel: "matrix-js", + accountId: "ops", + sessionKey: "agent:ops:main", + mainSessionKey: "agent:ops:main", + matchedBy: "binding.account", + })); + + const handler = createMatrixRoomMessageHandler({ + client: { + getUserId: async () => "@bot:example.org", + } as never, + core: { + channel: { + pairing: { + readAllowFromStore: async () => [] as string[], + upsertPairingRequest: async () => ({ code: "ABCDEFGH", created: false }), + }, + commands: { + shouldHandleTextCommands: () => false, + }, + text: { + hasControlCommand: () => false, + }, + routing: { + resolveAgentRoute, + }, + }, + } as never, + cfg: {} as never, + accountId: "ops", + runtime: { + error: () => {}, + } as never, + logger: { + info: () => {}, + warn: () => {}, + } as never, + logVerboseMessage: () => {}, + allowFrom: [], + mentionRegexes: [], + groupPolicy: "open", + replyToMode: "off", + threadReplies: "inbound", + dmEnabled: true, + dmPolicy: "open", + textLimit: 8_000, + mediaMaxBytes: 10_000_000, + startupMs: 0, + startupGraceMs: 0, + directTracker: { + isDirectMessage: async () => true, + }, + getRoomInfo: async () => ({ altAliases: [] }), + getMemberDisplayName: async () => "sender", + }); + + await handler("!room:example.org", { + type: EventType.RoomMessage, + sender: "@user:example.org", + event_id: "$event2", + origin_server_ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + "m.mentions": { room: true }, + }, + } as MatrixRawEvent); + + expect(resolveAgentRoute).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "matrix-js", + accountId: "ops", + }), + ); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/handler.ts b/extensions/matrix-js/src/matrix/monitor/handler.ts new file mode 100644 index 00000000000..38313f3399e --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/handler.ts @@ -0,0 +1,713 @@ +import { + createReplyPrefixOptions, + createTypingCallbacks, + formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, + 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 { + normalizeMatrixAllowList, + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, +} from "./allowlist.js"; +import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; +import { downloadMatrixMedia } from "./media.js"; +import { resolveMentions } from "./mentions.js"; +import { deliverMatrixReplies } from "./replies.js"; +import { resolveMatrixRoomConfig } from "./rooms.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 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 locationContent = event.content as LocationMessageEventContent; + const isLocationEvent = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); + if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) { + return; + } + logVerboseMessage( + `matrix: room.message recv 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 (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 (dmPolicy !== "pairing") { + logVerboseMessage( + `matrix: blocked 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})`); + } + + 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 route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix-js", + accountId, + peer: { + kind: isDirectMessage ? "direct" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); + 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, + 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 = (cfg.messages?.ackReaction ?? "").trim(); + const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + 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 new file mode 100644 index 00000000000..a9b1372f9c3 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/index.ts @@ -0,0 +1,400 @@ +import { format } from "node:util"; +import { + GROUP_POLICY_BLOCKED_LABEL, + mergeAllowlist, + 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 { 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"; + +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 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"); + + // 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 guidance. + if (auth.encryption && client.crypto) { + try { + const status = await client.getOwnDeviceVerificationStatus(); + if (status.verified) { + logger.info("matrix: device is verified and ready for encrypted rooms"); + } else { + logger.info( + "matrix: device not verified — run 'openclaw matrix-js verify device ' to enable E2EE", + ); + } + } catch (err) { + logger.debug?.("Failed to resolve matrix-js verification status (non-fatal)", { + error: String(err), + }); + } + } + + await new Promise((resolve) => { + const onAbort = () => { + try { + 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 new file mode 100644 index 00000000000..582c4b4facf --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/location.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 00000000000..7127b1fc67c --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/media.test.ts @@ -0,0 +1,102 @@ +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 new file mode 100644 index 00000000000..bfd31b51138 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/media.ts @@ -0,0 +1,117 @@ +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 new file mode 100644 index 00000000000..f1ee615e7ef --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/mentions.test.ts @@ -0,0 +1,154 @@ +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 new file mode 100644 index 00000000000..aa67386221a --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/mentions.ts @@ -0,0 +1,52 @@ +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 new file mode 100644 index 00000000000..326eafdf1c1 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/replies.test.ts @@ -0,0 +1,132 @@ +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 new file mode 100644 index 00000000000..fb4f4d24ac4 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/replies.ts @@ -0,0 +1,100 @@ +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 new file mode 100644 index 00000000000..095f1dc307a --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/room-info.ts @@ -0,0 +1,65 @@ +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 new file mode 100644 index 00000000000..21fe5a90474 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/rooms.test.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000000..b52c10ef3f2 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/rooms.ts @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000000..3c90e08dbfd --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/threads.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000000..f54d7735819 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/types.ts @@ -0,0 +1,28 @@ +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", +} 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/monitor/verification-utils.test.ts b/extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts new file mode 100644 index 00000000000..5093e73939d --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/verification-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + isMatrixVerificationEventType, + isMatrixVerificationNoticeBody, + isMatrixVerificationRequestMsgType, + isMatrixVerificationRoomMessage, +} from "./verification-utils.js"; + +describe("matrix verification message classifiers", () => { + it("recognizes verification event types", () => { + expect(isMatrixVerificationEventType("m.key.verification.start")).toBe(true); + expect(isMatrixVerificationEventType("m.room.message")).toBe(false); + }); + + it("recognizes verification request message type", () => { + expect(isMatrixVerificationRequestMsgType("m.key.verification.request")).toBe(true); + expect(isMatrixVerificationRequestMsgType("m.text")).toBe(false); + }); + + it("recognizes verification notice bodies", () => { + expect( + isMatrixVerificationNoticeBody("Matrix verification started with @alice:example.org."), + ).toBe(true); + expect(isMatrixVerificationNoticeBody("hello world")).toBe(false); + }); + + it("classifies verification room messages", () => { + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.key.verification.request", + body: "verify request", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.notice", + body: "Matrix verification cancelled by @alice:example.org.", + }), + ).toBe(true); + expect( + isMatrixVerificationRoomMessage({ + msgtype: "m.text", + body: "normal chat message", + }), + ).toBe(false); + }); +}); diff --git a/extensions/matrix-js/src/matrix/monitor/verification-utils.ts b/extensions/matrix-js/src/matrix/monitor/verification-utils.ts new file mode 100644 index 00000000000..d777167c4ff --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/verification-utils.ts @@ -0,0 +1,44 @@ +const VERIFICATION_EVENT_PREFIX = "m.key.verification."; +const VERIFICATION_REQUEST_MSGTYPE = "m.key.verification.request"; + +const VERIFICATION_NOTICE_PREFIXES = [ + "Matrix verification request received from ", + "Matrix verification is ready with ", + "Matrix verification started with ", + "Matrix verification completed with ", + "Matrix verification cancelled by ", + "Matrix verification SAS with ", +]; + +function trimMaybeString(input: unknown): string { + return typeof input === "string" ? input.trim() : ""; +} + +export function isMatrixVerificationEventType(type: unknown): boolean { + return trimMaybeString(type).startsWith(VERIFICATION_EVENT_PREFIX); +} + +export function isMatrixVerificationRequestMsgType(msgtype: unknown): boolean { + return trimMaybeString(msgtype) === VERIFICATION_REQUEST_MSGTYPE; +} + +export function isMatrixVerificationNoticeBody(body: unknown): boolean { + const text = trimMaybeString(body); + return VERIFICATION_NOTICE_PREFIXES.some((prefix) => text.startsWith(prefix)); +} + +export function isMatrixVerificationRoomMessage(content: { + msgtype?: unknown; + body?: unknown; +}): boolean { + return ( + isMatrixVerificationRequestMsgType(content.msgtype) || + (trimMaybeString(content.msgtype) === "m.notice" && + isMatrixVerificationNoticeBody(content.body)) + ); +} + +export const matrixVerificationConstants = { + eventPrefix: VERIFICATION_EVENT_PREFIX, + requestMsgtype: VERIFICATION_REQUEST_MSGTYPE, +} as const; diff --git a/extensions/matrix-js/src/matrix/poll-types.test.ts b/extensions/matrix-js/src/matrix/poll-types.test.ts new file mode 100644 index 00000000000..7f1797d99c6 --- /dev/null +++ b/extensions/matrix-js/src/matrix/poll-types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { 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"]); + }); +}); diff --git a/extensions/matrix-js/src/matrix/poll-types.ts b/extensions/matrix-js/src/matrix/poll-types.ts new file mode 100644 index 00000000000..c3bebd38875 --- /dev/null +++ b/extensions/matrix-js/src/matrix/poll-types.ts @@ -0,0 +1,167 @@ +/** + * 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 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 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 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 parsePollStartContent(content: PollStartContent): PollSummary | 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); + if (!question) { + return null; + } + + const answers = poll.answers + .map((answer) => getTextContent(answer)) + .filter((a) => a.trim().length > 0); + + return { + eventId: "", + roomId: "", + sender: "", + senderName: "", + question, + answers, + kind: poll.kind ?? "m.poll.disclosed", + maxSelections: poll.max_selections ?? 1, + }; +} + +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 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 isMultiple = (poll.maxSelections ?? 1) > 1; + const maxSelections = isMultiple ? Math.max(1, answers.length) : 1; + const fallbackText = buildPollFallbackText( + question, + answers.map((answer) => getTextContent(answer)), + ); + + return { + [M_POLL_START]: { + question: buildTextContent(question), + kind: isMultiple ? "m.poll.undisclosed" : "m.poll.disclosed", + max_selections: maxSelections, + answers, + }, + "m.text": fallbackText, + "org.matrix.msc1767.text": fallbackText, + }; +} diff --git a/extensions/matrix-js/src/matrix/probe.test.ts b/extensions/matrix-js/src/matrix/probe.test.ts new file mode 100644 index 00000000000..a15c433185c --- /dev/null +++ b/extensions/matrix-js/src/matrix/probe.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createMatrixClientMock = vi.fn(); +const isBunRuntimeMock = vi.fn(() => false); + +vi.mock("./client.js", () => ({ + createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), + isBunRuntime: () => isBunRuntimeMock(), +})); + +import { probeMatrix } from "./probe.js"; + +describe("probeMatrix", () => { + beforeEach(() => { + vi.clearAllMocks(); + isBunRuntimeMock.mockReturnValue(false); + createMatrixClientMock.mockResolvedValue({ + getUserId: vi.fn(async () => "@bot:example.org"), + }); + }); + + it("passes undefined userId when not provided", async () => { + const result = await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + timeoutMs: 1234, + }); + + expect(result.ok).toBe(true); + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: undefined, + accessToken: "tok", + localTimeoutMs: 1234, + }); + }); + + it("trims provided userId before client creation", async () => { + await probeMatrix({ + homeserver: "https://matrix.example.org", + accessToken: "tok", + userId: " @bot:example.org ", + timeoutMs: 500, + }); + + expect(createMatrixClientMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok", + localTimeoutMs: 500, + }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/probe.ts b/extensions/matrix-js/src/matrix/probe.ts new file mode 100644 index 00000000000..91261f95118 --- /dev/null +++ b/extensions/matrix-js/src/matrix/probe.ts @@ -0,0 +1,70 @@ +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/profile.test.ts b/extensions/matrix-js/src/matrix/profile.test.ts new file mode 100644 index 00000000000..a85a96f4e5f --- /dev/null +++ b/extensions/matrix-js/src/matrix/profile.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isSupportedMatrixAvatarSource, + syncMatrixOwnProfile, + type MatrixProfileSyncResult, +} from "./profile.js"; + +function createClientStub() { + return { + getUserProfile: vi.fn(async () => ({})), + setDisplayName: vi.fn(async () => {}), + setAvatarUrl: vi.fn(async () => {}), + uploadContent: vi.fn(async () => "mxc://example/avatar"), + }; +} + +function expectNoUpdates(result: MatrixProfileSyncResult) { + expect(result.displayNameUpdated).toBe(false); + expect(result.avatarUpdated).toBe(false); +} + +describe("matrix profile sync", () => { + it("skips when no desired profile values are provided", async () => { + const client = createClientStub(); + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + }); + + expect(result.skipped).toBe(true); + expectNoUpdates(result); + expect(client.setDisplayName).not.toHaveBeenCalled(); + expect(client.setAvatarUrl).not.toHaveBeenCalled(); + }); + + it("updates display name when desired name differs", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Old Name", + avatar_url: "mxc://example/existing", + }); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + displayName: "New Name", + }); + + expect(result.skipped).toBe(false); + expect(result.displayNameUpdated).toBe(true); + expect(result.avatarUpdated).toBe(false); + expect(client.setDisplayName).toHaveBeenCalledWith("New Name"); + }); + + it("does not update when name and avatar already match", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/avatar", + }); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + displayName: "Bot", + avatarUrl: "mxc://example/avatar", + }); + + expect(result.skipped).toBe(false); + expectNoUpdates(result); + expect(client.setDisplayName).not.toHaveBeenCalled(); + expect(client.setAvatarUrl).not.toHaveBeenCalled(); + }); + + it("converts http avatar URL by uploading and then updates profile avatar", async () => { + const client = createClientStub(); + client.getUserProfile.mockResolvedValue({ + displayname: "Bot", + avatar_url: "mxc://example/old", + }); + client.uploadContent.mockResolvedValue("mxc://example/new-avatar"); + const loadAvatarFromUrl = vi.fn(async () => ({ + buffer: Buffer.from("avatar-bytes"), + contentType: "image/png", + fileName: "avatar.png", + })); + + const result = await syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarUrl: "https://cdn.example.org/avatar.png", + loadAvatarFromUrl, + }); + + expect(result.convertedAvatarFromHttp).toBe(true); + expect(result.resolvedAvatarUrl).toBe("mxc://example/new-avatar"); + expect(result.avatarUpdated).toBe(true); + expect(loadAvatarFromUrl).toHaveBeenCalledWith( + "https://cdn.example.org/avatar.png", + 10 * 1024 * 1024, + ); + expect(client.setAvatarUrl).toHaveBeenCalledWith("mxc://example/new-avatar"); + }); + + it("rejects unsupported avatar URL schemes", async () => { + const client = createClientStub(); + + await expect( + syncMatrixOwnProfile({ + client, + userId: "@bot:example.org", + avatarUrl: "file:///tmp/avatar.png", + }), + ).rejects.toThrow("Matrix avatar URL must be an mxc:// URI or an http(s) URL."); + }); + + it("recognizes supported avatar sources", () => { + expect(isSupportedMatrixAvatarSource("mxc://example/avatar")).toBe(true); + expect(isSupportedMatrixAvatarSource("https://example.org/avatar.png")).toBe(true); + expect(isSupportedMatrixAvatarSource("http://example.org/avatar.png")).toBe(true); + expect(isSupportedMatrixAvatarSource("ftp://example.org/avatar.png")).toBe(false); + }); +}); diff --git a/extensions/matrix-js/src/matrix/profile.ts b/extensions/matrix-js/src/matrix/profile.ts new file mode 100644 index 00000000000..2cee6aa5e2a --- /dev/null +++ b/extensions/matrix-js/src/matrix/profile.ts @@ -0,0 +1,143 @@ +import type { MatrixClient } from "./sdk.js"; + +export const MATRIX_PROFILE_AVATAR_MAX_BYTES = 10 * 1024 * 1024; + +type MatrixProfileClient = Pick< + MatrixClient, + "getUserProfile" | "setDisplayName" | "setAvatarUrl" | "uploadContent" +>; + +type MatrixProfileLoadResult = { + buffer: Buffer; + contentType?: string; + fileName?: string; +}; + +export type MatrixProfileSyncResult = { + skipped: boolean; + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + convertedAvatarFromHttp: boolean; +}; + +function normalizeOptionalText(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function isMatrixMxcUri(value: string): boolean { + return value.trim().toLowerCase().startsWith("mxc://"); +} + +export function isMatrixHttpAvatarUri(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return normalized.startsWith("https://") || normalized.startsWith("http://"); +} + +export function isSupportedMatrixAvatarSource(value: string): boolean { + return isMatrixMxcUri(value) || isMatrixHttpAvatarUri(value); +} + +async function resolveAvatarUrl(params: { + client: MatrixProfileClient; + avatarUrl: string | null; + avatarMaxBytes: number; + loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; +}): Promise<{ resolvedAvatarUrl: string | null; convertedAvatarFromHttp: boolean }> { + const avatarUrl = normalizeOptionalText(params.avatarUrl); + if (!avatarUrl) { + return { + resolvedAvatarUrl: null, + convertedAvatarFromHttp: false, + }; + } + + if (isMatrixMxcUri(avatarUrl)) { + return { + resolvedAvatarUrl: avatarUrl, + convertedAvatarFromHttp: false, + }; + } + + if (!isMatrixHttpAvatarUri(avatarUrl)) { + throw new Error("Matrix avatar URL must be an mxc:// URI or an http(s) URL."); + } + + if (!params.loadAvatarFromUrl) { + throw new Error("Matrix avatar URL conversion requires a media loader."); + } + + const media = await params.loadAvatarFromUrl(avatarUrl, params.avatarMaxBytes); + const uploadedMxc = await params.client.uploadContent( + media.buffer, + media.contentType, + media.fileName || "avatar", + ); + + return { + resolvedAvatarUrl: uploadedMxc, + convertedAvatarFromHttp: true, + }; +} + +export async function syncMatrixOwnProfile(params: { + client: MatrixProfileClient; + userId: string; + displayName?: string | null; + avatarUrl?: string | null; + avatarMaxBytes?: number; + loadAvatarFromUrl?: (url: string, maxBytes: number) => Promise; +}): Promise { + const desiredDisplayName = normalizeOptionalText(params.displayName); + const avatar = await resolveAvatarUrl({ + client: params.client, + avatarUrl: params.avatarUrl ?? null, + avatarMaxBytes: params.avatarMaxBytes ?? MATRIX_PROFILE_AVATAR_MAX_BYTES, + loadAvatarFromUrl: params.loadAvatarFromUrl, + }); + const desiredAvatarUrl = avatar.resolvedAvatarUrl; + + if (!desiredDisplayName && !desiredAvatarUrl) { + return { + skipped: true, + displayNameUpdated: false, + avatarUpdated: false, + resolvedAvatarUrl: null, + convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, + }; + } + + let currentDisplayName: string | undefined; + let currentAvatarUrl: string | undefined; + try { + const currentProfile = await params.client.getUserProfile(params.userId); + currentDisplayName = normalizeOptionalText(currentProfile.displayname) ?? undefined; + currentAvatarUrl = normalizeOptionalText(currentProfile.avatar_url) ?? undefined; + } catch { + // If profile fetch fails, attempt writes directly. + } + + let displayNameUpdated = false; + let avatarUpdated = false; + + if (desiredDisplayName && currentDisplayName !== desiredDisplayName) { + await params.client.setDisplayName(desiredDisplayName); + displayNameUpdated = true; + } + if (desiredAvatarUrl && currentAvatarUrl !== desiredAvatarUrl) { + await params.client.setAvatarUrl(desiredAvatarUrl); + avatarUpdated = true; + } + + return { + skipped: false, + displayNameUpdated, + avatarUpdated, + resolvedAvatarUrl: desiredAvatarUrl, + convertedAvatarFromHttp: avatar.convertedAvatarFromHttp, + }; +} diff --git a/extensions/matrix-js/src/matrix/sdk.test.ts b/extensions/matrix-js/src/matrix/sdk.test.ts new file mode 100644 index 00000000000..01945313241 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk.test.ts @@ -0,0 +1,1279 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +class FakeMatrixEvent extends EventEmitter { + private readonly roomId: string; + private readonly eventId: string; + private readonly sender: string; + private readonly type: string; + private readonly ts: number; + private readonly content: Record; + private readonly stateKey?: string; + private readonly unsigned?: { + age?: number; + redacted_because?: unknown; + }; + private readonly decryptionFailure: boolean; + + constructor(params: { + roomId: string; + eventId: string; + sender: string; + type: string; + ts: number; + content: Record; + stateKey?: string; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; + decryptionFailure?: boolean; + }) { + super(); + this.roomId = params.roomId; + this.eventId = params.eventId; + this.sender = params.sender; + this.type = params.type; + this.ts = params.ts; + this.content = params.content; + this.stateKey = params.stateKey; + this.unsigned = params.unsigned; + this.decryptionFailure = params.decryptionFailure === true; + } + + getRoomId(): string { + return this.roomId; + } + + getId(): string { + return this.eventId; + } + + getSender(): string { + return this.sender; + } + + getType(): string { + return this.type; + } + + getTs(): number { + return this.ts; + } + + getContent(): Record { + return this.content; + } + + getUnsigned(): { age?: number; redacted_because?: unknown } { + return this.unsigned ?? {}; + } + + getStateKey(): string | undefined { + return this.stateKey; + } + + isDecryptionFailure(): boolean { + return this.decryptionFailure; + } +} + +type MatrixJsClientStub = EventEmitter & { + startClient: ReturnType; + stopClient: ReturnType; + initRustCrypto: ReturnType; + getUserId: ReturnType; + getDeviceId: ReturnType; + getJoinedRooms: ReturnType; + getJoinedRoomMembers: ReturnType; + getStateEvent: ReturnType; + getAccountData: ReturnType; + setAccountData: ReturnType; + getRoomIdForAlias: ReturnType; + sendMessage: ReturnType; + sendEvent: ReturnType; + sendStateEvent: ReturnType; + redactEvent: ReturnType; + getProfileInfo: ReturnType; + joinRoom: ReturnType; + mxcUrlToHttp: ReturnType; + uploadContent: ReturnType; + fetchRoomEvent: ReturnType; + sendTyping: ReturnType; + getRoom: ReturnType; + getRooms: ReturnType; + getCrypto: ReturnType; + decryptEventIfNeeded: ReturnType; +}; + +function createMatrixJsClientStub(): MatrixJsClientStub { + const client = new EventEmitter() as MatrixJsClientStub; + client.startClient = vi.fn(async () => {}); + client.stopClient = vi.fn(); + client.initRustCrypto = vi.fn(async () => {}); + client.getUserId = vi.fn(() => "@bot:example.org"); + client.getDeviceId = vi.fn(() => "DEVICE123"); + client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] })); + client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} })); + client.getStateEvent = vi.fn(async () => ({})); + client.getAccountData = vi.fn(() => undefined); + client.setAccountData = vi.fn(async () => {}); + client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" })); + client.sendMessage = vi.fn(async () => ({ event_id: "$sent" })); + client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" })); + client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" })); + client.redactEvent = vi.fn(async () => ({ event_id: "$redact" })); + client.getProfileInfo = vi.fn(async () => ({})); + client.joinRoom = vi.fn(async () => ({})); + client.mxcUrlToHttp = vi.fn(() => null); + client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" })); + client.fetchRoomEvent = vi.fn(async () => ({})); + client.sendTyping = vi.fn(async () => {}); + client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false })); + client.getRooms = vi.fn(() => []); + client.getCrypto = vi.fn(() => undefined); + client.decryptEventIfNeeded = vi.fn(async () => {}); + return client; +} + +let matrixJsClient = createMatrixJsClientStub(); +let lastCreateClientOpts: Record | null = null; + +vi.mock("matrix-js-sdk", () => ({ + ClientEvent: { Event: "event", Room: "Room" }, + MatrixEventEvent: { Decrypted: "decrypted" }, + createClient: vi.fn((opts: Record) => { + lastCreateClientOpts = opts; + return matrixJsClient; + }), +})); + +import { MatrixClient } from "./sdk.js"; + +describe("MatrixClient request hardening", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("blocks absolute endpoints unless explicitly allowed", async () => { + const fetchMock = vi.fn(async () => { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow( + "Absolute Matrix endpoint is blocked by default", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("blocks cross-protocol redirects when absolute endpoints are allowed", async () => { + const fetchMock = vi.fn(async () => { + return new Response("", { + status: 302, + headers: { + location: "http://evil.example.org/next", + }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + + await expect( + client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }), + ).rejects.toThrow("Blocked cross-protocol redirect"); + }); + + it("strips authorization when redirect crosses origin", async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => { + calls.push({ + url: String(url), + headers: new Headers(init?.headers), + }); + if (calls.length === 1) { + return new Response("", { + status: 302, + headers: { location: "https://cdn.example.org/next" }, + }); + } + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token"); + await client.doRequest("GET", "https://matrix.example.org/start", undefined, undefined, { + allowAbsoluteEndpoint: true, + }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toBe("https://matrix.example.org/start"); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer token"); + expect(calls[1]?.url).toBe("https://cdn.example.org/next"); + expect(calls[1]?.headers.get("authorization")).toBeNull(); + }); + + it("aborts requests after timeout", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("aborted")); + }); + }); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + localTimeoutMs: 25, + }); + + const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami"); + const assertion = expect(pending).rejects.toThrow("aborted"); + await vi.advanceTimersByTimeAsync(30); + + await assertion; + }); +}); + +describe("MatrixClient event bridge", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("emits room.message only after encrypted events decrypt", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const messageEvents: Array<{ roomId: string; type: string }> = []; + + client.on("room.message", (roomId, event) => { + messageEvents.push({ roomId, type: event.type }); + }); + + await client.start(); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.emit("event", encrypted); + expect(messageEvents).toHaveLength(0); + + encrypted.emit("decrypted", decrypted); + // Simulate a second normal event emission from the SDK after decryption. + matrixJsClient.emit("event", decrypted); + expect(messageEvents).toEqual([ + { + roomId: "!room:example.org", + type: "m.room.message", + }, + ]); + }); + + it("emits room.failed_decryption when decrypting fails", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + const delivered: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + await client.start(); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", decrypted, new Error("decrypt failed")); + + expect(failed).toEqual(["decrypt failed"]); + expect(delivered).toHaveLength(0); + }); + + it("retries failed decryption and emits room.message after late key availability", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + const delivered: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(1_600); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(failed).toEqual(["missing room key"]); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("retries failed decryptions immediately on crypto key update signals", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const failed: string[] = []; + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + encrypted.emit("decrypted", decrypted); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + expect(delivered).toHaveLength(0); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("stops decryption retries after hitting retry cap", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token"); + const failed: string[] = []; + + client.on("room.failed_decryption", (_roomId, _event, error) => { + failed.push(error.message); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + + matrixJsClient.decryptEventIfNeeded = vi.fn(async () => { + throw new Error("still missing key"); + }); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + expect(failed).toEqual(["missing room key"]); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + + await vi.advanceTimersByTimeAsync(200_000); + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8); + }); + + it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => { + vi.useFakeTimers(); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const delivered: string[] = []; + const cryptoListeners = new Map void>(); + + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + cryptoListeners.set(eventName, listener); + }), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + client.on("room.message", (_roomId, event) => { + delivered.push(event.type); + }); + + const encrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.encrypted", + ts: Date.now(), + content: {}, + decryptionFailure: true, + }); + const decrypted = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$event", + sender: "@alice:example.org", + type: "m.room.message", + ts: Date.now(), + content: { + msgtype: "m.text", + body: "hello", + }, + }); + + const releaseRetryRef: { current?: () => void } = {}; + matrixJsClient.decryptEventIfNeeded = vi.fn( + async () => + await new Promise((resolve) => { + releaseRetryRef.current = () => { + encrypted.emit("decrypted", decrypted); + resolve(); + }; + }), + ); + + await client.start(); + matrixJsClient.emit("event", encrypted); + encrypted.emit("decrypted", encrypted, new Error("missing room key")); + + const trigger = cryptoListeners.get("crypto.keyBackupDecryptionKeyCached"); + expect(trigger).toBeTypeOf("function"); + trigger?.(); + trigger?.(); + await Promise.resolve(); + + expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + releaseRetryRef.current?.(); + await Promise.resolve(); + expect(delivered).toEqual(["m.room.message"]); + }); + + it("emits room.invite when a membership invite targets the current user", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + const inviteMembership = new FakeMatrixEvent({ + roomId: "!room:example.org", + eventId: "$invite", + sender: "@alice:example.org", + type: "m.room.member", + ts: Date.now(), + stateKey: "@bot:example.org", + content: { + membership: "invite", + }, + }); + + matrixJsClient.emit("event", inviteMembership); + + expect(invites).toEqual(["!room:example.org"]); + }); + + it("emits room.invite when SDK emits Room event with invite membership", async () => { + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + matrixJsClient.emit("Room", { + roomId: "!invite:example.org", + getMyMembership: () => "invite", + }); + + expect(invites).toEqual(["!invite:example.org"]); + }); + + it("replays outstanding invite rooms at startup", async () => { + matrixJsClient.getRooms = vi.fn(() => [ + { + roomId: "!pending:example.org", + getMyMembership: () => "invite", + }, + { + roomId: "!joined:example.org", + getMyMembership: () => "join", + }, + ]); + + const client = new MatrixClient("https://matrix.example.org", "token"); + const invites: string[] = []; + client.on("room.invite", (roomId) => { + invites.push(roomId); + }); + + await client.start(); + + expect(invites).toEqual(["!pending:example.org"]); + }); +}); + +describe("MatrixClient crypto bootstrapping", () => { + beforeEach(() => { + matrixJsClient = createMatrixJsClientStub(); + lastCreateClientOpts = null; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("passes cryptoDatabasePrefix into initRustCrypto", async () => { + matrixJsClient.getCrypto = vi.fn(() => undefined); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + + await client.start(); + + expect(matrixJsClient.initRustCrypto).toHaveBeenCalledWith({ + cryptoDatabasePrefix: "openclaw-matrix-test", + }); + }); + + it("bootstraps cross-signing with setupNewCrossSigning enabled", async () => { + const bootstrapCrossSigning = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + await client.start(); + + expect(bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("retries bootstrap with forced reset when initial publish/verification is incomplete", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + password: "secret-password", + }); + const bootstrapSpy = vi + .fn() + .mockResolvedValueOnce({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }) + .mockResolvedValueOnce({ + crossSigningReady: true, + crossSigningPublished: true, + ownDeviceVerified: true, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(2); + expect(bootstrapSpy.mock.calls[1]?.[1]).toEqual({ + forceResetCrossSigning: true, + strict: true, + }); + }); + + it("does not force-reset bootstrap when password is unavailable", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ on: vi.fn() })); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + const bootstrapSpy = vi.fn().mockResolvedValue({ + crossSigningReady: false, + crossSigningPublished: false, + ownDeviceVerified: false, + }); + ( + client as unknown as { + cryptoBootstrapper: { bootstrap: typeof bootstrapSpy }; + } + ).cryptoBootstrapper.bootstrap = bootstrapSpy; + + await client.start(); + + expect(bootstrapSpy).toHaveBeenCalledTimes(1); + }); + + it("provides secret storage callbacks and resolves stored recovery key", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-")); + const recoveryKeyPath = path.join(tmpDir, "recovery-key.json"); + const privateKeyBase64 = Buffer.from([1, 2, 3, 4]).toString("base64"); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSSKEY", + privateKeyBase64, + }), + "utf8", + ); + + new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath, + }); + + const callbacks = (lastCreateClientOpts?.cryptoCallbacks ?? null) as { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + } | null; + expect(callbacks?.getSecretStorageKey).toBeTypeOf("function"); + + const resolved = await callbacks?.getSecretStorageKey?.( + { keys: { SSSSKEY: { algorithm: "m.secret_storage.v1.aes-hmac-sha2" } } }, + "m.cross_signing.master", + ); + expect(resolved?.[0]).toBe("SSSSKEY"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("provides a matrix-js-sdk logger to createClient", () => { + new MatrixClient("https://matrix.example.org", "token"); + const logger = (lastCreateClientOpts?.logger ?? null) as { + debug?: (...args: unknown[]) => void; + getChild?: (namespace: string) => unknown; + } | null; + expect(logger).not.toBeNull(); + expect(logger?.debug).toBeTypeOf("function"); + expect(logger?.getChild).toBeTypeOf("function"); + }); + + it("schedules periodic crypto snapshot persistence with fake timers", async () => { + vi.useFakeTimers(); + const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + idbSnapshotPath: path.join(os.tmpdir(), "matrix-idb-interval.json"), + cryptoDatabasePrefix: "openclaw-matrix-interval", + }); + + await client.start(); + const callsAfterStart = databasesSpy.mock.calls.length; + + await vi.advanceTimersByTimeAsync(60_000); + expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart); + + client.stop(); + const callsAfterStop = databasesSpy.mock.calls.length; + await vi.advanceTimersByTimeAsync(120_000); + expect(databasesSpy.mock.calls.length).toBe(callsAfterStop); + }); + + it("reports own verification status when crypto marks device as verified", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.encryptionEnabled).toBe(true); + expect(status.verified).toBe(true); + expect(status.userId).toBe("@bot:example.org"); + expect(status.deviceId).toBe("DEVICE123"); + }); + + it("verifies with a provided recovery key and reports success", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + expect(encoded).toBeTypeOf("string"); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + const bootstrapCrossSigning = vi.fn(async () => {}); + const getSecretStorageStatus = vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })); + const getDeviceVerificationStatus = vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning, + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + getSecretStorageStatus, + getDeviceVerificationStatus, + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-key-")); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"), + }); + await client.start(); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(true); + expect(result.verified).toBe(true); + expect(result.recoveryKeyStored).toBe(true); + expect(result.deviceId).toBe("DEVICE123"); + expect(bootstrapSecretStorage).toHaveBeenCalled(); + expect(bootstrapCrossSigning).toHaveBeenCalled(); + }); + + it("reports detailed room-key backup health", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => "11"), + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1, 2, 3])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "11", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "doRequest").mockResolvedValue({ version: "11" }); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.backupVersion).toBe("11"); + expect(status.backup).toEqual({ + serverVersion: "11", + activeVersion: "11", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: false, + keyLoadError: null, + }); + }); + + it("tries loading backup keys from secret storage when key is missing from cache", async () => { + const getActiveSessionBackupVersion = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("9"); + const getSessionBackupPrivateKey = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(new Uint8Array([1])); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion, + getSessionBackupPrivateKey, + loadSessionBackupPrivateKeyFromSecretStorage, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const backup = await client.getRoomKeyBackupStatus(); + expect(backup).toMatchObject({ + serverVersion: "9", + activeVersion: "9", + trusted: true, + matchesDecryptionKey: true, + decryptionKeyCached: true, + keyLoadAttempted: true, + keyLoadError: null, + }); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + }); + + it("reports why backup key loading failed during status checks", async () => { + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => { + throw new Error("secret storage key is not available"); + }); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => null), + getSessionBackupPrivateKey: vi.fn(async () => null), + loadSessionBackupPrivateKeyFromSecretStorage, + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const backup = await client.getRoomKeyBackupStatus(); + expect(backup.keyLoadAttempted).toBe(true); + expect(backup.keyLoadError).toContain("secret storage key is not available"); + expect(backup.decryptionKeyCached).toBe(false); + }); + + it("restores room keys from backup after loading key from secret storage", async () => { + const getActiveSessionBackupVersion = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("9") + .mockResolvedValue("9"); + const loadSessionBackupPrivateKeyFromSecretStorage = vi.fn(async () => {}); + const restoreKeyBackup = vi.fn(async () => ({ imported: 4, total: 10 })); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion, + loadSessionBackupPrivateKeyFromSecretStorage, + restoreKeyBackup, + getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "9", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const result = await client.restoreRoomKeyBackup(); + expect(result.success).toBe(true); + expect(result.backupVersion).toBe("9"); + expect(result.imported).toBe(4); + expect(result.total).toBe(10); + expect(result.loadedFromSecretStorage).toBe(true); + expect(loadSessionBackupPrivateKeyFromSecretStorage).toHaveBeenCalledTimes(1); + expect(restoreKeyBackup).toHaveBeenCalledTimes(1); + }); + + it("fails restore when backup key cannot be loaded on this device", async () => { + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + getActiveSessionBackupVersion: vi.fn(async () => null), + getSessionBackupPrivateKey: vi.fn(async () => null), + getKeyBackupInfo: vi.fn(async () => ({ + algorithm: "m.megolm_backup.v1.curve25519-aes-sha2", + auth_data: {}, + version: "3", + })), + isKeyBackupTrusted: vi.fn(async () => ({ + trusted: true, + matchesDecryptionKey: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + + const result = await client.restoreRoomKeyBackup(); + expect(result.success).toBe(false); + expect(result.error).toContain("cannot load backup keys from secret storage"); + expect(result.backupVersion).toBe("3"); + expect(result.backup.matchesDecryptionKey).toBe(false); + }); + + it("reports bootstrap failure when cross-signing keys are not published", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(false); + expect(result.error).toContain( + "Cross-signing bootstrap finished but server keys are still not published", + ); + }); + + it("reports bootstrap success when own device is verified and keys are published", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(true); + expect(result.verification.verified).toBe(true); + expect(result.crossSigning.published).toBe(true); + expect(result.cryptoBootstrap).not.toBeNull(); + }); + + it("creates a key backup during bootstrap when none exists on the server", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + let backupChecks = 0; + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + backupChecks += 1; + return backupChecks >= 2 ? { version: "7" } : {}; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("7"); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ setupNewKeyBackup: true }), + ); + }); + + it("does not recreate key backup during bootstrap when one already exists", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + const bootstrapSecretStorage = vi.fn(async () => {}); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + vi.spyOn(client, "doRequest").mockImplementation(async (_method, endpoint) => { + if (String(endpoint).includes("/room_keys/version")) { + return { version: "9" }; + } + return {}; + }); + + const result = await client.bootstrapOwnDeviceVerification(); + + expect(result.success).toBe(true); + expect(result.verification.backupVersion).toBe("9"); + const bootstrapSecretStorageCalls = bootstrapSecretStorage.mock.calls as Array< + [{ setupNewKeyBackup?: boolean }?] + >; + expect(bootstrapSecretStorageCalls.some((call) => Boolean(call[0]?.setupNewKeyBackup))).toBe( + false, + ); + }); + + it("does not report bootstrap errors when final verification state is healthy", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getSecretStorageStatus: vi.fn(async () => ({ + ready: true, + defaultKeyId: "SSSSKEY", + secretStorageKeyValidityMap: { SSSSKEY: true }, + })), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + + const result = await client.bootstrapOwnDeviceVerification({ + recoveryKey: "not-a-valid-recovery-key", + }); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix-js/src/matrix/sdk.ts new file mode 100644 index 00000000000..36096cc0fa6 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk.ts @@ -0,0 +1,1113 @@ +// Polyfill IndexedDB for WASM crypto in Node.js +import "fake-indexeddb/auto"; +import { EventEmitter } from "node:events"; +import { + ClientEvent, + createClient as createMatrixJsClient, + type MatrixClient as MatrixJsClient, + type MatrixEvent, +} from "matrix-js-sdk"; +import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; +import { createMatrixJsSdkClientLogger } from "./client/logging.js"; +import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js"; +import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js"; +import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js"; +import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js"; +import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js"; +import { MatrixAuthedHttpClient } from "./sdk/http-client.js"; +import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js"; +import { ConsoleLogger, LogService, noop } from "./sdk/logger.js"; +import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js"; +import { type HttpMethod, type QueryParams } from "./sdk/transport.js"; +import type { + MatrixClientEventMap, + MatrixCryptoBootstrapApi, + MatrixDeviceVerificationStatusLike, + MatrixRawEvent, + MessageEventContent, +} from "./sdk/types.js"; +import { MatrixVerificationManager } from "./sdk/verification-manager.js"; + +export { ConsoleLogger, LogService }; +export type { + DimensionalFileInfo, + FileWithThumbnailInfo, + TimedFileInfo, + VideoFileInfo, +} from "./sdk/types.js"; +export type { + EncryptedFile, + LocationMessageEventContent, + MatrixRawEvent, + MessageEventContent, + TextualMessageEventContent, +} from "./sdk/types.js"; + +export type MatrixOwnDeviceVerificationStatus = { + encryptionEnabled: boolean; + userId: string | null; + deviceId: string | null; + verified: boolean; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + recoveryKeyStored: boolean; + recoveryKeyCreatedAt: string | null; + recoveryKeyId: string | null; + backupVersion: string | null; + backup: MatrixRoomKeyBackupStatus; +}; + +export type MatrixRoomKeyBackupStatus = { + serverVersion: string | null; + activeVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + decryptionKeyCached: boolean | null; + keyLoadAttempted: boolean; + keyLoadError: string | null; +}; + +export type MatrixRoomKeyBackupRestoreResult = { + success: boolean; + error?: string; + backupVersion: string | null; + imported: number; + total: number; + loadedFromSecretStorage: boolean; + restoredAt?: string; + backup: MatrixRoomKeyBackupStatus; +}; + +export type MatrixRecoveryKeyVerificationResult = MatrixOwnDeviceVerificationStatus & { + success: boolean; + verifiedAt?: string; + error?: string; +}; + +export type MatrixOwnCrossSigningPublicationStatus = { + userId: string | null; + masterKeyPublished: boolean; + selfSigningKeyPublished: boolean; + userSigningKeyPublished: boolean; + published: boolean; +}; + +export type MatrixVerificationBootstrapResult = { + success: boolean; + error?: string; + verification: MatrixOwnDeviceVerificationStatus; + crossSigning: MatrixOwnCrossSigningPublicationStatus; + pendingVerifications: number; + cryptoBootstrap: MatrixCryptoBootstrapResult | null; +}; + +function isMatrixDeviceVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return ( + status?.isVerified?.() === true || + status?.localVerified === true || + status?.crossSigningVerified === true || + status?.signedByOwner === true + ); +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export class MatrixClient { + private readonly client: MatrixJsClient; + private readonly emitter = new EventEmitter(); + private readonly httpClient: MatrixAuthedHttpClient; + private readonly localTimeoutMs: number; + private readonly initialSyncLimit?: number; + private readonly encryptionEnabled: boolean; + private readonly password?: string; + private readonly idbSnapshotPath?: string; + private readonly cryptoDatabasePrefix?: string; + private bridgeRegistered = false; + private started = false; + private cryptoBootstrapped = false; + private selfUserId: string | null; + private readonly dmRoomIds = new Set(); + private cryptoInitialized = false; + private readonly decryptBridge: MatrixDecryptBridge; + private readonly verificationManager = new MatrixVerificationManager(); + private readonly recoveryKeyStore: MatrixRecoveryKeyStore; + private readonly cryptoBootstrapper: MatrixCryptoBootstrapper; + private readonly autoBootstrapCrypto: boolean; + private stopPersistPromise: Promise | null = null; + + readonly dms = { + update: async (): Promise => { + await this.refreshDmCache(); + }, + isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId), + }; + + crypto?: MatrixCryptoFacade; + + constructor( + homeserver: string, + accessToken: string, + _storage?: unknown, + _cryptoStorage?: unknown, + opts: { + userId?: string; + password?: string; + deviceId?: string; + localTimeoutMs?: number; + encryption?: boolean; + initialSyncLimit?: number; + recoveryKeyPath?: string; + idbSnapshotPath?: string; + cryptoDatabasePrefix?: string; + autoBootstrapCrypto?: boolean; + } = {}, + ) { + this.httpClient = new MatrixAuthedHttpClient(homeserver, accessToken); + this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000); + this.initialSyncLimit = opts.initialSyncLimit; + this.encryptionEnabled = opts.encryption === true; + this.password = opts.password; + this.idbSnapshotPath = opts.idbSnapshotPath; + this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; + this.selfUserId = opts.userId?.trim() || null; + this.autoBootstrapCrypto = opts.autoBootstrapCrypto !== false; + this.recoveryKeyStore = new MatrixRecoveryKeyStore(opts.recoveryKeyPath); + const cryptoCallbacks = this.encryptionEnabled + ? this.recoveryKeyStore.buildCryptoCallbacks() + : undefined; + this.client = createMatrixJsClient({ + baseUrl: homeserver, + accessToken, + userId: opts.userId, + deviceId: opts.deviceId, + logger: createMatrixJsSdkClientLogger("MatrixClient"), + localTimeoutMs: this.localTimeoutMs, + cryptoCallbacks: cryptoCallbacks as never, + verificationMethods: [ + VerificationMethod.Sas, + VerificationMethod.ShowQrCode, + VerificationMethod.ScanQrCode, + VerificationMethod.Reciprocate, + ], + }); + this.decryptBridge = new MatrixDecryptBridge({ + client: this.client, + toRaw: (event) => matrixEventToRaw(event), + emitDecryptedEvent: (roomId, event) => { + this.emitter.emit("room.decrypted_event", roomId, event); + }, + emitMessage: (roomId, event) => { + this.emitter.emit("room.message", roomId, event); + }, + emitFailedDecryption: (roomId, event, error) => { + this.emitter.emit("room.failed_decryption", roomId, event, error); + }, + }); + this.cryptoBootstrapper = new MatrixCryptoBootstrapper({ + getUserId: () => this.getUserId(), + getPassword: () => opts.password, + getDeviceId: () => this.client.getDeviceId(), + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + decryptBridge: this.decryptBridge, + }); + + if (this.encryptionEnabled) { + this.crypto = createMatrixCryptoFacade({ + client: this.client, + verificationManager: this.verificationManager, + recoveryKeyStore: this.recoveryKeyStore, + getRoomStateEvent: (roomId, eventType, stateKey = "") => + this.getRoomStateEvent(roomId, eventType, stateKey), + downloadContent: (mxcUrl) => this.downloadContent(mxcUrl), + }); + } + } + + on( + eventName: TEvent, + listener: (...args: MatrixClientEventMap[TEvent]) => void, + ): this; + on(eventName: string, listener: (...args: unknown[]) => void): this; + on(eventName: string, listener: (...args: unknown[]) => void): this { + this.emitter.on(eventName, listener as (...args: unknown[]) => void); + return this; + } + + off( + eventName: TEvent, + listener: (...args: MatrixClientEventMap[TEvent]) => void, + ): this; + off(eventName: string, listener: (...args: unknown[]) => void): this; + off(eventName: string, listener: (...args: unknown[]) => void): this { + this.emitter.off(eventName, listener as (...args: unknown[]) => void); + return this; + } + + private idbPersistTimer: ReturnType | null = null; + + async start(): Promise { + if (this.started) { + return; + } + + this.registerBridge(); + await this.initializeCryptoIfNeeded(); + + await this.client.startClient({ + initialSyncLimit: this.initialSyncLimit, + }); + if (this.autoBootstrapCrypto) { + await this.bootstrapCryptoIfNeeded(); + } + this.started = true; + this.emitOutstandingInviteEvents(); + await this.refreshDmCache().catch(noop); + } + + async prepareForOneOff(): Promise { + if (!this.encryptionEnabled) { + return; + } + await this.initializeCryptoIfNeeded(); + if (!this.crypto) { + return; + } + try { + const joinedRooms = await this.getJoinedRooms(); + await this.crypto.prepare(joinedRooms); + } catch { + // One-off commands should continue even if crypto room prep is incomplete. + } + } + + stop(): void { + if (this.idbPersistTimer) { + clearInterval(this.idbPersistTimer); + this.idbPersistTimer = null; + } + this.decryptBridge.stop(); + // Final persist on shutdown + this.stopPersistPromise = persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); + this.client.stopClient(); + this.started = false; + } + + async stopAndPersist(): Promise { + this.stop(); + await this.stopPersistPromise; + } + + private async bootstrapCryptoIfNeeded(): Promise { + if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) { + return; + } + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return; + } + const initial = await this.cryptoBootstrapper.bootstrap(crypto); + if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) { + if (this.password?.trim()) { + try { + const repaired = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: true, + strict: true, + }); + if (repaired.crossSigningPublished && repaired.ownDeviceVerified !== false) { + LogService.info( + "MatrixClientLite", + "Cross-signing/bootstrap recovered after forced reset", + ); + } + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Failed to recover cross-signing/bootstrap with forced reset:", + err, + ); + } + } else { + LogService.warn( + "MatrixClientLite", + "Cross-signing/bootstrap incomplete and no password is configured for UIA fallback", + ); + } + } + this.cryptoBootstrapped = true; + } + + private async initializeCryptoIfNeeded(): Promise { + if (!this.encryptionEnabled || this.cryptoInitialized) { + return; + } + + // Restore persisted IndexedDB crypto store before initializing WASM crypto. + await restoreIdbFromDisk(this.idbSnapshotPath); + + try { + await this.client.initRustCrypto({ + cryptoDatabasePrefix: this.cryptoDatabasePrefix, + }); + this.cryptoInitialized = true; + + // Persist the crypto store after successful init (captures fresh keys on first run). + await persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }); + + // Periodically persist to capture new Olm sessions and room keys. + this.idbPersistTimer = setInterval(() => { + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); + }, 60_000); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err); + } + } + + async getUserId(): Promise { + const fromClient = this.client.getUserId(); + if (fromClient) { + this.selfUserId = fromClient; + return fromClient; + } + if (this.selfUserId) { + return this.selfUserId; + } + const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as { + user_id?: string; + }; + const resolved = whoami.user_id?.trim(); + if (!resolved) { + throw new Error("Matrix whoami did not return user_id"); + } + this.selfUserId = resolved; + return resolved; + } + + async getJoinedRooms(): Promise { + const joined = await this.client.getJoinedRooms(); + return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : []; + } + + async getJoinedRoomMembers(roomId: string): Promise { + const members = await this.client.getJoinedRoomMembers(roomId); + const joined = members?.joined; + if (!joined || typeof joined !== "object") { + return []; + } + return Object.keys(joined); + } + + async getRoomStateEvent( + roomId: string, + eventType: string, + stateKey = "", + ): Promise> { + const state = await this.client.getStateEvent(roomId, eventType, stateKey); + return (state ?? {}) as Record; + } + + async getAccountData(eventType: string): Promise | undefined> { + const event = this.client.getAccountData(eventType as never); + return (event?.getContent() as Record | undefined) ?? undefined; + } + + async setAccountData(eventType: string, content: Record): Promise { + await this.client.setAccountData(eventType as never, content as never); + await this.refreshDmCache().catch(noop); + } + + async resolveRoom(aliasOrRoomId: string): Promise { + if (aliasOrRoomId.startsWith("!")) { + return aliasOrRoomId; + } + if (!aliasOrRoomId.startsWith("#")) { + return aliasOrRoomId; + } + try { + const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId); + return resolved.room_id ?? null; + } catch { + return null; + } + } + + async sendMessage(roomId: string, content: MessageEventContent): Promise { + const sent = await this.client.sendMessage(roomId, content as never); + return sent.event_id; + } + + async sendEvent( + roomId: string, + eventType: string, + content: Record, + ): Promise { + const sent = await this.client.sendEvent(roomId, eventType as never, content as never); + return sent.event_id; + } + + async sendStateEvent( + roomId: string, + eventType: string, + stateKey: string, + content: Record, + ): Promise { + const sent = await this.client.sendStateEvent( + roomId, + eventType as never, + content as never, + stateKey, + ); + return sent.event_id; + } + + async redactEvent(roomId: string, eventId: string, reason?: string): Promise { + const sent = await this.client.redactEvent( + roomId, + eventId, + undefined, + reason?.trim() ? { reason } : undefined, + ); + return sent.event_id; + } + + async doRequest( + method: HttpMethod, + endpoint: string, + qs?: QueryParams, + body?: unknown, + opts?: { allowAbsoluteEndpoint?: boolean }, + ): Promise { + return await this.httpClient.requestJson({ + method, + endpoint, + qs, + body, + timeoutMs: this.localTimeoutMs, + allowAbsoluteEndpoint: opts?.allowAbsoluteEndpoint, + }); + } + + async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> { + return await this.client.getProfileInfo(userId); + } + + async setDisplayName(displayName: string): Promise { + await this.client.setDisplayName(displayName); + } + + async setAvatarUrl(avatarUrl: string): Promise { + await this.client.setAvatarUrl(avatarUrl); + } + + async joinRoom(roomId: string): Promise { + await this.client.joinRoom(roomId); + } + + mxcToHttp(mxcUrl: string): string | null { + return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true); + } + + async downloadContent(mxcUrl: string, allowRemote = true): Promise { + const parsed = parseMxc(mxcUrl); + if (!parsed) { + throw new Error(`Invalid Matrix content URI: ${mxcUrl}`); + } + const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`; + const response = await this.httpClient.requestRaw({ + method: "GET", + endpoint, + qs: { allow_remote: allowRemote }, + timeoutMs: this.localTimeoutMs, + }); + return response; + } + + async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise { + const uploaded = await this.client.uploadContent(new Uint8Array(file), { + type: contentType || "application/octet-stream", + name: filename, + includeFilename: Boolean(filename), + }); + return uploaded.content_uri; + } + + async getEvent(roomId: string, eventId: string): Promise> { + return (await this.client.fetchRoomEvent(roomId, eventId)) as Record; + } + + async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise { + await this.client.sendTyping(roomId, typing, timeoutMs); + } + + async sendReadReceipt(roomId: string, eventId: string): Promise { + await this.httpClient.requestJson({ + method: "POST", + endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent( + eventId, + )}`, + body: {}, + timeoutMs: this.localTimeoutMs, + }); + } + + async getRoomKeyBackupStatus(): Promise { + if (!this.encryptionEnabled) { + return { + serverVersion: null, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + const serverVersionFallback = await this.resolveRoomKeyBackupVersion(); + if (!crypto) { + return { + serverVersion: serverVersionFallback, + activeVersion: null, + trusted: null, + matchesDecryptionKey: null, + decryptionKeyCached: null, + keyLoadAttempted: false, + keyLoadError: null, + }; + } + + let { activeVersion, decryptionKeyCached } = await this.resolveRoomKeyBackupLocalState(crypto); + let { serverVersion, trusted, matchesDecryptionKey } = + await this.resolveRoomKeyBackupTrustState(crypto, serverVersionFallback); + let keyLoadAttempted = false; + let keyLoadError: string | null = null; + if (serverVersion && decryptionKeyCached === false) { + if (typeof crypto.loadSessionBackupPrivateKeyFromSecretStorage === "function") { + keyLoadAttempted = true; + try { + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + } catch (err) { + keyLoadError = err instanceof Error ? err.message : String(err); + } + ({ activeVersion, decryptionKeyCached } = + await this.resolveRoomKeyBackupLocalState(crypto)); + ({ serverVersion, trusted, matchesDecryptionKey } = + await this.resolveRoomKeyBackupTrustState(crypto, serverVersion)); + } else { + keyLoadError = + "Matrix crypto backend does not support loading backup keys from secret storage"; + } + } + + return { + serverVersion, + activeVersion, + trusted, + matchesDecryptionKey, + decryptionKeyCached, + keyLoadAttempted, + keyLoadError, + }; + } + + async getOwnDeviceVerificationStatus(): Promise { + const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary(); + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + const deviceId = this.client.getDeviceId()?.trim() || null; + const backup = await this.getRoomKeyBackupStatus(); + + if (!this.encryptionEnabled) { + return { + encryptionEnabled: false, + userId, + deviceId, + verified: false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: backup.serverVersion, + backup, + }; + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + let deviceStatus: MatrixDeviceVerificationStatusLike | null = null; + if (crypto && userId && deviceId && typeof crypto.getDeviceVerificationStatus === "function") { + deviceStatus = await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null); + } + + return { + encryptionEnabled: true, + userId, + deviceId, + verified: isMatrixDeviceVerified(deviceStatus), + localVerified: deviceStatus?.localVerified === true, + crossSigningVerified: deviceStatus?.crossSigningVerified === true, + signedByOwner: deviceStatus?.signedByOwner === true, + recoveryKeyStored: Boolean(recoveryKey), + recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null, + recoveryKeyId: recoveryKey?.keyId ?? null, + backupVersion: backup.serverVersion, + backup, + }; + } + + async verifyWithRecoveryKey( + rawRecoveryKey: string, + ): Promise { + const fail = async (error: string): Promise => ({ + success: false, + error, + ...(await this.getOwnDeviceVerificationStatus()), + }); + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + const trimmedRecoveryKey = rawRecoveryKey.trim(); + if (!trimmedRecoveryKey) { + return await fail("Matrix recovery key is required"); + } + + let defaultKeyId: string | null | undefined = undefined; + if (typeof crypto.getSecretStorageStatus === "function") { + const status = await crypto.getSecretStorageStatus().catch(() => null); + defaultKeyId = status?.defaultKeyId; + } + + try { + this.recoveryKeyStore.storeEncodedRecoveryKey({ + encodedPrivateKey: trimmedRecoveryKey, + keyId: defaultKeyId, + }); + } catch (err) { + return await fail(err instanceof Error ? err.message : String(err)); + } + + await this.cryptoBootstrapper.bootstrap(crypto); + const status = await this.getOwnDeviceVerificationStatus(); + if (!status.verified) { + return { + success: false, + error: + "Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.", + ...status, + }; + } + + return { + success: true, + verifiedAt: new Date().toISOString(), + ...status, + }; + } + + async restoreRoomKeyBackup( + params: { + recoveryKey?: string; + } = {}, + ): Promise { + let loadedFromSecretStorage = false; + const fail = async (error: string): Promise => { + const backup = await this.getRoomKeyBackupStatus(); + return { + success: false, + error, + backupVersion: backup.serverVersion, + imported: 0, + total: 0, + loadedFromSecretStorage, + backup, + }; + }; + + if (!this.encryptionEnabled) { + return await fail("Matrix encryption is disabled for this client"); + } + + await this.initializeCryptoIfNeeded(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + return await fail("Matrix crypto is not available (start client with encryption enabled)"); + } + + try { + const rawRecoveryKey = params.recoveryKey?.trim(); + if (rawRecoveryKey) { + let defaultKeyId: string | null | undefined = undefined; + if (typeof crypto.getSecretStorageStatus === "function") { + const status = await crypto.getSecretStorageStatus().catch(() => null); + defaultKeyId = status?.defaultKeyId; + } + this.recoveryKeyStore.storeEncodedRecoveryKey({ + encodedPrivateKey: rawRecoveryKey, + keyId: defaultKeyId, + }); + } + + let activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto); + 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.", + ); + } + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + loadedFromSecretStorage = true; + activeVersion = await this.resolveActiveRoomKeyBackupVersion(crypto); + } + if (!activeVersion) { + return await fail( + "Matrix key backup is not active on this device after loading from secret storage.", + ); + } + if (typeof crypto.restoreKeyBackup !== "function") { + return await fail("Matrix crypto backend does not support full key backup restore"); + } + + const restore = await crypto.restoreKeyBackup(); + const backup = await this.getRoomKeyBackupStatus(); + return { + success: true, + backupVersion: activeVersion, + imported: typeof restore.imported === "number" ? restore.imported : 0, + total: typeof restore.total === "number" ? restore.total : 0, + loadedFromSecretStorage, + restoredAt: new Date().toISOString(), + backup, + }; + } catch (err) { + return await fail(err instanceof Error ? err.message : String(err)); + } + } + + async getOwnCrossSigningPublicationStatus(): Promise { + const userId = this.client.getUserId() ?? this.selfUserId ?? null; + if (!userId) { + return { + userId: null, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + + try { + const response = (await this.doRequest("POST", "/_matrix/client/v3/keys/query", undefined, { + device_keys: { [userId]: [] as string[] }, + })) as { + master_keys?: Record; + self_signing_keys?: Record; + user_signing_keys?: Record; + }; + const masterKeyPublished = Boolean(response.master_keys?.[userId]); + const selfSigningKeyPublished = Boolean(response.self_signing_keys?.[userId]); + const userSigningKeyPublished = Boolean(response.user_signing_keys?.[userId]); + return { + userId, + masterKeyPublished, + selfSigningKeyPublished, + userSigningKeyPublished, + published: masterKeyPublished && selfSigningKeyPublished && userSigningKeyPublished, + }; + } catch { + return { + userId, + masterKeyPublished: false, + selfSigningKeyPublished: false, + userSigningKeyPublished: false, + published: false, + }; + } + } + + async bootstrapOwnDeviceVerification(params?: { + recoveryKey?: string; + forceResetCrossSigning?: boolean; + }): Promise { + const pendingVerifications = async (): Promise => + this.crypto ? (await this.crypto.listVerifications()).length : 0; + if (!this.encryptionEnabled) { + return { + success: false, + error: "Matrix encryption is disabled for this client", + verification: await this.getOwnDeviceVerificationStatus(), + crossSigning: await this.getOwnCrossSigningPublicationStatus(), + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: null, + }; + } + + let bootstrapError: string | undefined; + let bootstrapSummary: MatrixCryptoBootstrapResult | null = null; + try { + await this.initializeCryptoIfNeeded(); + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (!crypto) { + throw new Error("Matrix crypto is not available (start client with encryption enabled)"); + } + + const rawRecoveryKey = params?.recoveryKey?.trim(); + if (rawRecoveryKey) { + let defaultKeyId: string | null | undefined = undefined; + if (typeof crypto.getSecretStorageStatus === "function") { + const status = await crypto.getSecretStorageStatus().catch(() => null); + defaultKeyId = status?.defaultKeyId; + } + this.recoveryKeyStore.storeEncodedRecoveryKey({ + encodedPrivateKey: rawRecoveryKey, + keyId: defaultKeyId, + }); + } + + bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, { + forceResetCrossSigning: params?.forceResetCrossSigning === true, + strict: true, + }); + await this.ensureRoomKeyBackupEnabled(crypto); + } catch (err) { + bootstrapError = err instanceof Error ? err.message : String(err); + } + + const verification = await this.getOwnDeviceVerificationStatus(); + const crossSigning = await this.getOwnCrossSigningPublicationStatus(); + const success = verification.verified && crossSigning.published; + const error = success + ? undefined + : (bootstrapError ?? + "Matrix verification bootstrap did not produce a verified device with published cross-signing keys"); + return { + success, + error, + verification, + crossSigning, + pendingVerifications: await pendingVerifications(), + cryptoBootstrap: bootstrapSummary, + }; + } + + private async resolveActiveRoomKeyBackupVersion( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + if (typeof crypto.getActiveSessionBackupVersion !== "function") { + return null; + } + const version = await crypto.getActiveSessionBackupVersion().catch(() => null); + return normalizeOptionalString(version); + } + + private async resolveCachedRoomKeyBackupDecryptionKey( + crypto: MatrixCryptoBootstrapApi, + ): Promise { + if (typeof crypto.getSessionBackupPrivateKey !== "function") { + return null; + } + const key = await crypto.getSessionBackupPrivateKey().catch(() => null); + return key ? key.length > 0 : false; + } + + private async resolveRoomKeyBackupLocalState( + crypto: MatrixCryptoBootstrapApi, + ): Promise<{ activeVersion: string | null; decryptionKeyCached: boolean | null }> { + const [activeVersion, decryptionKeyCached] = await Promise.all([ + this.resolveActiveRoomKeyBackupVersion(crypto), + this.resolveCachedRoomKeyBackupDecryptionKey(crypto), + ]); + return { activeVersion, decryptionKeyCached }; + } + + private async resolveRoomKeyBackupTrustState( + crypto: MatrixCryptoBootstrapApi, + fallbackVersion: string | null, + ): Promise<{ + serverVersion: string | null; + trusted: boolean | null; + matchesDecryptionKey: boolean | null; + }> { + let serverVersion = fallbackVersion; + let trusted: boolean | null = null; + let matchesDecryptionKey: boolean | null = null; + if (typeof crypto.getKeyBackupInfo === "function") { + const info = await crypto.getKeyBackupInfo().catch(() => null); + serverVersion = normalizeOptionalString(info?.version) ?? serverVersion; + if (info && typeof crypto.isKeyBackupTrusted === "function") { + const trustInfo = await crypto.isKeyBackupTrusted(info).catch(() => null); + trusted = typeof trustInfo?.trusted === "boolean" ? trustInfo.trusted : null; + matchesDecryptionKey = + typeof trustInfo?.matchesDecryptionKey === "boolean" + ? trustInfo.matchesDecryptionKey + : null; + } + } + return { serverVersion, trusted, matchesDecryptionKey }; + } + + private async resolveRoomKeyBackupVersion(): Promise { + try { + const response = (await this.doRequest("GET", "/_matrix/client/v3/room_keys/version")) as { + version?: string; + }; + return normalizeOptionalString(response.version); + } catch { + return null; + } + } + + private async ensureRoomKeyBackupEnabled(crypto: MatrixCryptoBootstrapApi): Promise { + const existingVersion = await this.resolveRoomKeyBackupVersion(); + if (existingVersion) { + return; + } + LogService.info( + "MatrixClientLite", + "No room key backup version found on server, creating one via secret storage bootstrap", + ); + await this.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto, { + setupNewKeyBackup: true, + }); + const createdVersion = await this.resolveRoomKeyBackupVersion(); + if (!createdVersion) { + throw new Error("Matrix room key backup is still missing after bootstrap"); + } + LogService.info("MatrixClientLite", `Room key backup enabled (version ${createdVersion})`); + } + + private registerBridge(): void { + if (this.bridgeRegistered) { + return; + } + this.bridgeRegistered = true; + + this.client.on(ClientEvent.Event, (event: MatrixEvent) => { + const roomId = event.getRoomId(); + if (!roomId) { + return; + } + + const raw = matrixEventToRaw(event); + const isEncryptedEvent = raw.type === "m.room.encrypted"; + this.emitter.emit("room.event", roomId, raw); + if (isEncryptedEvent) { + this.emitter.emit("room.encrypted_event", roomId, raw); + } else { + if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) { + this.emitter.emit("room.message", roomId, raw); + } + } + + const stateKey = raw.state_key ?? ""; + const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; + const membership = + raw.type === "m.room.member" + ? (raw.content as { membership?: string }).membership + : undefined; + if (stateKey && selfUserId && stateKey === selfUserId) { + if (membership === "invite") { + this.emitter.emit("room.invite", roomId, raw); + } else if (membership === "join") { + this.emitter.emit("room.join", roomId, raw); + } + } + + if (isEncryptedEvent) { + this.decryptBridge.attachEncryptedEvent(event, roomId); + } + }); + + // Some SDK invite transitions are surfaced as room lifecycle events instead of raw timeline events. + this.client.on(ClientEvent.Room, (room) => { + this.emitMembershipForRoom(room); + }); + } + + private emitMembershipForRoom(room: unknown): void { + const roomObj = room as { + roomId?: string; + getMyMembership?: () => string | null | undefined; + selfMembership?: string | null | undefined; + }; + const roomId = roomObj.roomId?.trim(); + if (!roomId) { + return; + } + const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined; + const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; + if (!selfUserId) { + return; + } + const raw: MatrixRawEvent = { + event_id: `$membership-${roomId}-${Date.now()}`, + type: "m.room.member", + sender: selfUserId, + state_key: selfUserId, + content: { membership }, + origin_server_ts: Date.now(), + unsigned: { age: 0 }, + }; + if (membership === "invite") { + this.emitter.emit("room.invite", roomId, raw); + return; + } + if (membership === "join") { + this.emitter.emit("room.join", roomId, raw); + } + } + + private emitOutstandingInviteEvents(): void { + const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms; + if (typeof listRooms !== "function") { + return; + } + const rooms = listRooms.call(this.client); + if (!Array.isArray(rooms)) { + return; + } + for (const room of rooms) { + this.emitMembershipForRoom(room); + } + } + + private async refreshDmCache(): Promise { + const direct = await this.getAccountData("m.direct"); + this.dmRoomIds.clear(); + if (!direct || typeof direct !== "object") { + return; + } + for (const value of Object.values(direct)) { + if (!Array.isArray(value)) { + continue; + } + for (const roomId of value) { + if (typeof roomId === "string" && roomId.trim()) { + this.dmRoomIds.add(roomId); + } + } + } + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts new file mode 100644 index 00000000000..7b15d3b4d56 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts @@ -0,0 +1,355 @@ +import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js"; +import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js"; + +function createBootstrapperDeps() { + return { + getUserId: vi.fn(async () => "@bot:example.org"), + getPassword: vi.fn(() => "super-secret-password"), + getDeviceId: vi.fn(() => "DEVICE123"), + verificationManager: { + trackVerificationRequest: vi.fn(), + }, + recoveryKeyStore: { + bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}), + }, + decryptBridge: { + bindCryptoRetrySignals: vi.fn(), + }, + }; +} + +function createCryptoApi(overrides?: Partial): MatrixCryptoBootstrapApi { + return { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + ...overrides, + }; +} + +describe("MatrixCryptoBootstrapper", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("bootstraps cross-signing/secret-storage and binds decrypt retry signals", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(crypto.bootstrapCrossSigning).toHaveBeenCalledWith( + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith( + crypto, + ); + expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledTimes(2); + expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledWith(crypto); + }); + + it("forces new cross-signing keys only when readiness check still fails", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + userHasCrossSigningKeys: vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("fails in strict mode when cross-signing keys are still unpublished", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + bootstrapCrossSigning: vi.fn(async () => {}), + isCrossSigningReady: vi.fn(async () => false), + userHasCrossSigningKeys: vi.fn(async () => false), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await expect(bootstrapper.bootstrap(crypto, { strict: true })).rejects.toThrow( + "Cross-signing bootstrap finished but server keys are still not published", + ); + }); + + it("uses password UIA fallback when null and dummy auth fail", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi.fn(async () => {}); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const bootstrapCrossSigningCalls = bootstrapCrossSigning.mock.calls as Array< + [ + { + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }?, + ] + >; + const authUploadDeviceSigningKeys = + bootstrapCrossSigningCalls[0]?.[0]?.authUploadDeviceSigningKeys; + expect(authUploadDeviceSigningKeys).toBeTypeOf("function"); + + const seenAuthStages: Array | null> = []; + const result = await authUploadDeviceSigningKeys?.(async (authData) => { + seenAuthStages.push(authData); + if (authData === null) { + throw new Error("need auth"); + } + if (authData.type === "m.login.dummy") { + throw new Error("dummy rejected"); + } + if (authData.type === "m.login.password") { + return "ok"; + } + throw new Error("unexpected auth stage"); + }); + + expect(result).toBe("ok"); + expect(seenAuthStages).toEqual([ + null, + { type: "m.login.dummy" }, + { + type: "m.login.password", + identifier: { type: "m.id.user", user: "@bot:example.org" }, + password: "super-secret-password", + }, + ]); + }); + + it("resets cross-signing when first bootstrap attempt throws", async () => { + const deps = createBootstrapperDeps(); + const bootstrapCrossSigning = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("first attempt failed")) + .mockResolvedValueOnce(undefined); + const crypto = createCryptoApi({ + bootstrapCrossSigning, + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(bootstrapCrossSigning).toHaveBeenCalledTimes(2); + expect(bootstrapCrossSigning).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys: expect.any(Function), + }), + ); + }); + + it("marks own device verified and cross-signs it when needed", async () => { + const deps = createBootstrapperDeps(); + const setDeviceVerified = vi.fn(async () => {}); + const crossSignDevice = vi.fn(async () => {}); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => false, + localVerified: false, + crossSigningVerified: false, + signedByOwner: false, + })), + setDeviceVerified, + crossSignDevice, + isCrossSigningReady: vi.fn(async () => true), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); + expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); + }); + + it("auto-accepts incoming verification requests from other users", async () => { + const deps = createBootstrapperDeps(); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(deps.verificationManager.trackVerificationRequest).toHaveBeenCalledWith( + verificationRequest, + ); + expect(verificationRequest.accept).toHaveBeenCalledTimes(1); + }); + + it("still auto-accepts verification when tracking summary throws", async () => { + const deps = createBootstrapperDeps(); + deps.verificationManager.trackVerificationRequest = vi.fn(() => { + throw new Error("summary failure"); + }); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(verificationRequest.accept).toHaveBeenCalledTimes(1); + }); + + it("skips auto-accept for requests that are no longer requested", async () => { + const deps = createBootstrapperDeps(); + const listeners = new Map void>(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + on: vi.fn((eventName: string, listener: (...args: unknown[]) => void) => { + listeners.set(eventName, listener); + }), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + const verificationRequest = { + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: false, + phase: VerificationPhase.Cancelled, + accepting: false, + declining: false, + accept: vi.fn(async () => {}), + }; + const listener = Array.from(listeners.entries()).find(([eventName]) => + eventName.toLowerCase().includes("verificationrequest"), + )?.[1]; + expect(listener).toBeTypeOf("function"); + await listener?.(verificationRequest); + + expect(verificationRequest.accept).not.toHaveBeenCalled(); + }); + + it("registers verification listeners only once across repeated bootstrap calls", async () => { + const deps = createBootstrapperDeps(); + const crypto = createCryptoApi({ + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + })), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + await bootstrapper.bootstrap(crypto); + + expect(crypto.on).toHaveBeenCalledTimes(1); + expect(deps.decryptBridge.bindCryptoRetrySignals).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts new file mode 100644 index 00000000000..9af37da24ae --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts @@ -0,0 +1,334 @@ +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import { VerificationPhase } from "matrix-js-sdk/lib/crypto-api/verification.js"; +import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; +import { LogService } from "./logger.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { + MatrixAuthDict, + MatrixCryptoBootstrapApi, + MatrixRawEvent, + MatrixUiAuthCallback, +} from "./types.js"; +import type { + MatrixVerificationManager, + MatrixVerificationRequestLike, +} from "./verification-manager.js"; + +export type MatrixCryptoBootstrapperDeps = { + getUserId: () => Promise; + getPassword?: () => string | undefined; + getDeviceId: () => string | null | undefined; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + decryptBridge: Pick, "bindCryptoRetrySignals">; +}; + +export type MatrixCryptoBootstrapOptions = { + forceResetCrossSigning?: boolean; + strict?: boolean; +}; + +export type MatrixCryptoBootstrapResult = { + crossSigningReady: boolean; + crossSigningPublished: boolean; + ownDeviceVerified: boolean | null; +}; + +export class MatrixCryptoBootstrapper { + private verificationHandlerRegistered = false; + + constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} + + async bootstrap( + crypto: MatrixCryptoBootstrapApi, + options: MatrixCryptoBootstrapOptions = {}, + ): Promise { + const strict = options.strict === true; + // Register verification listeners before expensive bootstrap work so incoming requests + // are not missed during startup. + this.registerVerificationRequestHandler(crypto); + await this.bootstrapSecretStorage(crypto, strict); + const crossSigning = await this.bootstrapCrossSigning(crypto, { + forceResetCrossSigning: options.forceResetCrossSigning === true, + strict, + }); + await this.bootstrapSecretStorage(crypto, strict); + const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, strict); + return { + crossSigningReady: crossSigning.ready, + crossSigningPublished: crossSigning.published, + ownDeviceVerified, + }; + } + + private createSigningKeysUiAuthCallback(params: { + userId: string; + password?: string; + }): MatrixUiAuthCallback { + return async (makeRequest: (authData: MatrixAuthDict | null) => Promise): Promise => { + try { + return await makeRequest(null); + } catch { + // Some homeservers require an explicit dummy UIA stage even when no user interaction is needed. + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!params.password?.trim()) { + throw new Error( + "Matrix cross-signing key upload requires UIA; provide matrix-js.password for m.login.password fallback", + ); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: params.userId }, + password: params.password, + }); + } + } + }; + } + + private async bootstrapCrossSigning( + crypto: MatrixCryptoBootstrapApi, + options: { forceResetCrossSigning: boolean; strict: boolean }, + ): Promise<{ ready: boolean; published: boolean }> { + const userId = await this.deps.getUserId(); + const authUploadDeviceSigningKeys = this.createSigningKeysUiAuthCallback({ + userId, + password: this.deps.getPassword?.(), + }); + const hasPublishedCrossSigningKeys = async (): Promise => { + if (typeof crypto.userHasCrossSigningKeys !== "function") { + return true; + } + try { + return await crypto.userHasCrossSigningKeys(userId, true); + } catch { + return false; + } + }; + const isCrossSigningReady = async (): Promise => { + if (typeof crypto.isCrossSigningReady !== "function") { + return true; + } + try { + return await crypto.isCrossSigningReady(); + } catch { + return false; + } + }; + + const finalize = async (): Promise<{ ready: boolean; published: boolean }> => { + const ready = await isCrossSigningReady(); + const published = await hasPublishedCrossSigningKeys(); + if (ready && published) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return { ready, published }; + } + const message = "Cross-signing bootstrap finished but server keys are still not published"; + LogService.warn("MatrixClientLite", message); + if (options.strict) { + throw new Error(message); + } + return { ready, published }; + }; + + if (options.forceResetCrossSigning) { + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err); + if (options.strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + return { ready: false, published: false }; + } + return await finalize(); + } + + // First pass: preserve existing cross-signing identity and ensure public keys are uploaded. + try { + await crypto.bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Initial cross-signing bootstrap failed, trying reset:", + err, + ); + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (resetErr) { + LogService.warn("MatrixClientLite", "Failed to bootstrap cross-signing:", resetErr); + if (options.strict) { + throw resetErr instanceof Error ? resetErr : new Error(String(resetErr)); + } + return { ready: false, published: false }; + } + } + + const firstPassReady = await isCrossSigningReady(); + const firstPassPublished = await hasPublishedCrossSigningKeys(); + if (firstPassReady && firstPassPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return { ready: true, published: true }; + } + + // Fallback: recover from broken local/server state by creating a fresh identity. + try { + await crypto.bootstrapCrossSigning({ + setupNewCrossSigning: true, + authUploadDeviceSigningKeys, + }); + } catch (err) { + LogService.warn("MatrixClientLite", "Fallback cross-signing bootstrap failed:", err); + if (options.strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + return { ready: false, published: false }; + } + + return await finalize(); + } + + private async bootstrapSecretStorage( + crypto: MatrixCryptoBootstrapApi, + strict = false, + ): Promise { + try { + await this.deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey(crypto); + LogService.info("MatrixClientLite", "Secret storage bootstrap complete"); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to bootstrap secret storage:", err); + if (strict) { + throw err instanceof Error ? err : new Error(String(err)); + } + } + } + + private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { + if (this.verificationHandlerRegistered) { + return; + } + this.verificationHandlerRegistered = true; + + // Auto-accept incoming verification requests from other users/devices. + crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { + const verificationRequest = request as MatrixVerificationRequestLike; + try { + this.deps.verificationManager.trackVerificationRequest(verificationRequest); + } catch (err) { + LogService.warn( + "MatrixClientLite", + `Failed to track verification request from ${verificationRequest.otherUserId}:`, + err, + ); + } + const otherUserId = verificationRequest.otherUserId; + const isSelfVerification = verificationRequest.isSelfVerification; + const initiatedByMe = verificationRequest.initiatedByMe; + const phase = + typeof verificationRequest.phase === "number" + ? verificationRequest.phase + : VerificationPhase.Requested; + const accepting = verificationRequest.accepting === true; + const declining = verificationRequest.declining === true; + + if (isSelfVerification || initiatedByMe) { + LogService.debug( + "MatrixClientLite", + `Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`, + ); + return; + } + if (phase !== VerificationPhase.Requested || accepting || declining) { + LogService.debug( + "MatrixClientLite", + `Skipping auto-accept for ${otherUserId} in phase=${phase} accepting=${accepting} declining=${declining}`, + ); + return; + } + + try { + LogService.info( + "MatrixClientLite", + `Auto-accepting verification request from ${otherUserId}`, + ); + await verificationRequest.accept(); + LogService.info( + "MatrixClientLite", + `Verification request from ${otherUserId} accepted, waiting for SAS...`, + ); + } catch (err) { + LogService.warn( + "MatrixClientLite", + `Failed to auto-accept verification from ${otherUserId}:`, + err, + ); + } + }); + + this.deps.decryptBridge.bindCryptoRetrySignals(crypto); + LogService.info("MatrixClientLite", "Verification request handler registered"); + } + + private async ensureOwnDeviceTrust( + crypto: MatrixCryptoBootstrapApi, + strict = false, + ): Promise { + const deviceId = this.deps.getDeviceId()?.trim(); + if (!deviceId) { + return null; + } + const userId = await this.deps.getUserId(); + + const deviceStatus = + typeof crypto.getDeviceVerificationStatus === "function" + ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) + : null; + const alreadyVerified = + deviceStatus?.isVerified?.() === true || + deviceStatus?.localVerified === true || + deviceStatus?.crossSigningVerified === true || + deviceStatus?.signedByOwner === true; + + if (alreadyVerified) { + return true; + } + + if (typeof crypto.setDeviceVerified === "function") { + await crypto.setDeviceVerified(userId, deviceId, true); + } + + if (typeof crypto.crossSignDevice === "function") { + const crossSigningReady = + typeof crypto.isCrossSigningReady === "function" + ? await crypto.isCrossSigningReady() + : true; + if (crossSigningReady) { + await crypto.crossSignDevice(deviceId); + } + } + + const refreshedStatus = + typeof crypto.getDeviceVerificationStatus === "function" + ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) + : null; + const verified = + refreshedStatus?.isVerified?.() === true || + refreshedStatus?.localVerified === true || + refreshedStatus?.crossSigningVerified === true || + refreshedStatus?.signedByOwner === true; + if (!verified && strict) { + throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`); + } + return verified; + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts b/extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts new file mode 100644 index 00000000000..9b1d9da719f --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-facade.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMatrixCryptoFacade } from "./crypto-facade.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixVerificationManager } from "./verification-manager.js"; + +describe("createMatrixCryptoFacade", () => { + it("detects encrypted rooms from cached room state", async () => { + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => true, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({ algorithm: "m.megolm.v1.aes-sha2" })), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + }); + + it("falls back to server room state when room cache has no encryption event", async () => { + const getRoomStateEvent = vi.fn(async () => ({ + algorithm: "m.megolm.v1.aes-sha2", + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => ({ + hasEncryptionStateEvent: () => false, + }), + getCrypto: () => undefined, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(), + listVerifications: vi.fn(async () => []), + requestVerification: vi.fn(), + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => null), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent, + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + await expect(facade.isRoomEncrypted("!room:example.org")).resolves.toBe(true); + expect(getRoomStateEvent).toHaveBeenCalledWith("!room:example.org", "m.room.encryption", ""); + }); + + it("forwards verification requests and uses client crypto API", async () => { + const crypto = { requestOwnUserVerification: vi.fn(async () => null) }; + const requestVerification = vi.fn(async () => ({ + id: "verification-1", + otherUserId: "@alice:example.org", + isSelfVerification: false, + initiatedByMe: true, + phase: 2, + phaseName: "ready", + pending: true, + methods: ["m.sas.v1"], + canAccept: false, + hasSas: false, + hasReciprocateQr: false, + completed: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + })); + const facade = createMatrixCryptoFacade({ + client: { + getRoom: () => null, + getCrypto: () => crypto, + }, + verificationManager: { + requestOwnUserVerification: vi.fn(async () => null), + listVerifications: vi.fn(async () => []), + requestVerification, + acceptVerification: vi.fn(), + cancelVerification: vi.fn(), + startVerification: vi.fn(), + generateVerificationQr: vi.fn(), + scanVerificationQr: vi.fn(), + confirmVerificationSas: vi.fn(), + mismatchVerificationSas: vi.fn(), + confirmVerificationReciprocateQr: vi.fn(), + getVerificationSas: vi.fn(), + } as unknown as MatrixVerificationManager, + recoveryKeyStore: { + getRecoveryKeySummary: vi.fn(() => ({ keyId: "KEY" })), + } as unknown as MatrixRecoveryKeyStore, + getRoomStateEvent: vi.fn(async () => ({})), + downloadContent: vi.fn(async () => Buffer.alloc(0)), + }); + + const result = await facade.requestVerification({ + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + + expect(requestVerification).toHaveBeenCalledWith(crypto, { + userId: "@alice:example.org", + deviceId: "DEVICE", + }); + expect(result.id).toBe("verification-1"); + await expect(facade.getRecoveryKey()).resolves.toMatchObject({ keyId: "KEY" }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts b/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts new file mode 100644 index 00000000000..e31131415cb --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-facade.ts @@ -0,0 +1,173 @@ +import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { EncryptedFile } from "./types.js"; +import type { + MatrixVerificationCryptoApi, + MatrixVerificationManager, + MatrixVerificationMethod, + MatrixVerificationSummary, +} from "./verification-manager.js"; + +type MatrixCryptoFacadeClient = { + getRoom: (roomId: string) => { hasEncryptionStateEvent: () => boolean } | null; + getCrypto: () => unknown; +}; + +export type MatrixCryptoFacade = { + prepare: (joinedRooms: string[]) => Promise; + updateSyncData: ( + toDeviceMessages: unknown, + otkCounts: unknown, + unusedFallbackKeyAlgs: unknown, + changedDeviceLists: unknown, + leftDeviceLists: unknown, + ) => Promise; + isRoomEncrypted: (roomId: string) => Promise; + requestOwnUserVerification: () => Promise; + encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit }>; + decryptMedia: (file: EncryptedFile) => Promise; + getRecoveryKey: () => Promise<{ + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null>; + listVerifications: () => Promise; + requestVerification: (params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }) => Promise; + acceptVerification: (id: string) => Promise; + cancelVerification: ( + id: string, + params?: { reason?: string; code?: string }, + ) => Promise; + startVerification: ( + id: string, + method?: MatrixVerificationMethod, + ) => Promise; + generateVerificationQr: (id: string) => Promise<{ qrDataBase64: string }>; + scanVerificationQr: (id: string, qrDataBase64: string) => Promise; + confirmVerificationSas: (id: string) => Promise; + mismatchVerificationSas: (id: string) => Promise; + confirmVerificationReciprocateQr: (id: string) => Promise; + getVerificationSas: ( + id: string, + ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; +}; + +export function createMatrixCryptoFacade(deps: { + client: MatrixCryptoFacadeClient; + verificationManager: MatrixVerificationManager; + recoveryKeyStore: MatrixRecoveryKeyStore; + getRoomStateEvent: ( + roomId: string, + eventType: string, + stateKey?: string, + ) => Promise>; + downloadContent: (mxcUrl: string) => Promise; +}): MatrixCryptoFacade { + return { + prepare: async (_joinedRooms: string[]) => { + // matrix-js-sdk performs crypto prep during startup; no extra work required here. + }, + updateSyncData: async ( + _toDeviceMessages: unknown, + _otkCounts: unknown, + _unusedFallbackKeyAlgs: unknown, + _changedDeviceLists: unknown, + _leftDeviceLists: unknown, + ) => { + // compatibility no-op + }, + isRoomEncrypted: async (roomId: string): Promise => { + const room = deps.client.getRoom(roomId); + if (room?.hasEncryptionStateEvent()) { + return true; + } + try { + const event = await deps.getRoomStateEvent(roomId, "m.room.encryption", ""); + return typeof event.algorithm === "string" && event.algorithm.length > 0; + } catch { + return false; + } + }, + requestOwnUserVerification: async (): Promise => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestOwnUserVerification(crypto); + }, + encryptMedia: async ( + buffer: Buffer, + ): Promise<{ buffer: Buffer; file: Omit }> => { + const encrypted = Attachment.encrypt(new Uint8Array(buffer)); + const mediaInfoJson = encrypted.mediaEncryptionInfo; + if (!mediaInfoJson) { + throw new Error("Matrix media encryption failed: missing media encryption info"); + } + const parsed = JSON.parse(mediaInfoJson) as EncryptedFile; + return { + buffer: Buffer.from(encrypted.encryptedData), + file: { + key: parsed.key, + iv: parsed.iv, + hashes: parsed.hashes, + v: parsed.v, + }, + }; + }, + decryptMedia: async (file: EncryptedFile): Promise => { + const encrypted = await deps.downloadContent(file.url); + const metadata: EncryptedFile = { + url: file.url, + key: file.key, + iv: file.iv, + hashes: file.hashes, + v: file.v, + }; + const attachment = new EncryptedAttachment( + new Uint8Array(encrypted), + JSON.stringify(metadata), + ); + const decrypted = Attachment.decrypt(attachment); + return Buffer.from(decrypted); + }, + getRecoveryKey: async () => { + return deps.recoveryKeyStore.getRecoveryKeySummary(); + }, + listVerifications: async () => { + return deps.verificationManager.listVerifications(); + }, + requestVerification: async (params) => { + const crypto = deps.client.getCrypto() as MatrixVerificationCryptoApi | undefined; + return await deps.verificationManager.requestVerification(crypto, params); + }, + acceptVerification: async (id) => { + return await deps.verificationManager.acceptVerification(id); + }, + cancelVerification: async (id, params) => { + return await deps.verificationManager.cancelVerification(id, params); + }, + startVerification: async (id, method = "sas") => { + return await deps.verificationManager.startVerification(id, method); + }, + generateVerificationQr: async (id) => { + return await deps.verificationManager.generateVerificationQr(id); + }, + scanVerificationQr: async (id, qrDataBase64) => { + return await deps.verificationManager.scanVerificationQr(id, qrDataBase64); + }, + confirmVerificationSas: async (id) => { + return await deps.verificationManager.confirmVerificationSas(id); + }, + mismatchVerificationSas: async (id) => { + return deps.verificationManager.mismatchVerificationSas(id); + }, + confirmVerificationReciprocateQr: async (id) => { + return deps.verificationManager.confirmVerificationReciprocateQr(id); + }, + getVerificationSas: async (id) => { + return deps.verificationManager.getVerificationSas(id); + }, + }; +} diff --git a/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts new file mode 100644 index 00000000000..1df9e8748bd --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/decrypt-bridge.ts @@ -0,0 +1,307 @@ +import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk"; +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import { LogService, noop } from "./logger.js"; + +type MatrixDecryptIfNeededClient = { + decryptEventIfNeeded?: ( + event: MatrixEvent, + opts?: { + isRetry?: boolean; + }, + ) => Promise; +}; + +type MatrixDecryptRetryState = { + event: MatrixEvent; + roomId: string; + eventId: string; + attempts: number; + inFlight: boolean; + timer: ReturnType | null; +}; + +type DecryptBridgeRawEvent = { + event_id: string; +}; + +type MatrixCryptoRetrySignalSource = { + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +const MATRIX_DECRYPT_RETRY_BASE_DELAY_MS = 1_500; +const MATRIX_DECRYPT_RETRY_MAX_DELAY_MS = 30_000; +const MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS = 8; + +function resolveDecryptRetryKey(roomId: string, eventId: string): string | null { + if (!roomId || !eventId) { + return null; + } + return `${roomId}|${eventId}`; +} + +function isDecryptionFailure(event: MatrixEvent): boolean { + return ( + typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" && + (event as { isDecryptionFailure: () => boolean }).isDecryptionFailure() + ); +} + +export class MatrixDecryptBridge { + private readonly trackedEncryptedEvents = new WeakSet(); + private readonly decryptedMessageDedupe = new Map(); + private readonly decryptRetries = new Map(); + private readonly failedDecryptionsNotified = new Set(); + private cryptoRetrySignalsBound = false; + + constructor( + private readonly deps: { + client: MatrixDecryptIfNeededClient; + toRaw: (event: MatrixEvent) => TRawEvent; + emitDecryptedEvent: (roomId: string, event: TRawEvent) => void; + emitMessage: (roomId: string, event: TRawEvent) => void; + emitFailedDecryption: (roomId: string, event: TRawEvent, error: Error) => void; + }, + ) {} + + shouldEmitUnencryptedMessage(roomId: string, eventId: string): boolean { + if (!eventId) { + return true; + } + const key = `${roomId}|${eventId}`; + const createdAt = this.decryptedMessageDedupe.get(key); + if (createdAt === undefined) { + return true; + } + this.decryptedMessageDedupe.delete(key); + return false; + } + + attachEncryptedEvent(event: MatrixEvent, roomId: string): void { + if (this.trackedEncryptedEvents.has(event)) { + return; + } + this.trackedEncryptedEvents.add(event); + event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => { + this.handleEncryptedEventDecrypted({ + roomId, + encryptedEvent: event, + decryptedEvent, + err, + }); + }); + } + + retryPendingNow(reason: string): void { + const pending = Array.from(this.decryptRetries.entries()); + if (pending.length === 0) { + return; + } + LogService.debug("MatrixClientLite", `Retrying pending decryptions due to ${reason}`); + for (const [retryKey, state] of pending) { + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + if (state.inFlight) { + continue; + } + this.runDecryptRetry(retryKey).catch(noop); + } + } + + bindCryptoRetrySignals(crypto: MatrixCryptoRetrySignalSource | undefined): void { + if (!crypto || this.cryptoRetrySignalsBound) { + return; + } + this.cryptoRetrySignalsBound = true; + + const trigger = (reason: string): void => { + this.retryPendingNow(reason); + }; + + crypto.on(CryptoEvent.KeyBackupDecryptionKeyCached, () => { + trigger("crypto.keyBackupDecryptionKeyCached"); + }); + crypto.on(CryptoEvent.RehydrationCompleted, () => { + trigger("dehydration.RehydrationCompleted"); + }); + crypto.on(CryptoEvent.DevicesUpdated, () => { + trigger("crypto.devicesUpdated"); + }); + crypto.on(CryptoEvent.KeysChanged, () => { + trigger("crossSigning.keysChanged"); + }); + } + + stop(): void { + for (const retryKey of this.decryptRetries.keys()) { + this.clearDecryptRetry(retryKey); + } + } + + private handleEncryptedEventDecrypted(params: { + roomId: string; + encryptedEvent: MatrixEvent; + decryptedEvent: MatrixEvent; + err?: Error; + }): void { + const decryptedRoomId = params.decryptedEvent.getRoomId() || params.roomId; + const decryptedRaw = this.deps.toRaw(params.decryptedEvent); + const retryEventId = decryptedRaw.event_id || params.encryptedEvent.getId() || ""; + const retryKey = resolveDecryptRetryKey(decryptedRoomId, retryEventId); + + if (params.err) { + this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (isDecryptionFailure(params.decryptedEvent)) { + this.emitFailedDecryptionOnce( + retryKey, + decryptedRoomId, + decryptedRaw, + new Error("Matrix event failed to decrypt"), + ); + this.scheduleDecryptRetry({ + event: params.encryptedEvent, + roomId: decryptedRoomId, + eventId: retryEventId, + }); + return; + } + + if (retryKey) { + this.clearDecryptRetry(retryKey); + } + this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id); + this.deps.emitDecryptedEvent(decryptedRoomId, decryptedRaw); + this.deps.emitMessage(decryptedRoomId, decryptedRaw); + } + + private emitFailedDecryptionOnce( + retryKey: string | null, + roomId: string, + event: TRawEvent, + error: Error, + ): void { + if (retryKey) { + if (this.failedDecryptionsNotified.has(retryKey)) { + return; + } + this.failedDecryptionsNotified.add(retryKey); + } + this.deps.emitFailedDecryption(roomId, event, error); + } + + private scheduleDecryptRetry(params: { + event: MatrixEvent; + roomId: string; + eventId: string; + }): void { + const retryKey = resolveDecryptRetryKey(params.roomId, params.eventId); + if (!retryKey) { + return; + } + const existing = this.decryptRetries.get(retryKey); + if (existing?.timer || existing?.inFlight) { + return; + } + const attempts = (existing?.attempts ?? 0) + 1; + if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) { + this.clearDecryptRetry(retryKey); + LogService.debug( + "MatrixClientLite", + `Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`, + ); + return; + } + const delayMs = Math.min( + MATRIX_DECRYPT_RETRY_BASE_DELAY_MS * 2 ** (attempts - 1), + MATRIX_DECRYPT_RETRY_MAX_DELAY_MS, + ); + const next: MatrixDecryptRetryState = { + event: params.event, + roomId: params.roomId, + eventId: params.eventId, + attempts, + inFlight: false, + timer: null, + }; + next.timer = setTimeout(() => { + this.runDecryptRetry(retryKey).catch(noop); + }, delayMs); + this.decryptRetries.set(retryKey, next); + } + + private async runDecryptRetry(retryKey: string): Promise { + const state = this.decryptRetries.get(retryKey); + if (!state || state.inFlight) { + return; + } + + state.inFlight = true; + state.timer = null; + const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function"; + if (!canDecrypt) { + this.clearDecryptRetry(retryKey); + return; + } + + try { + await this.deps.client.decryptEventIfNeeded?.(state.event, { + isRetry: true, + }); + } catch { + // Retry with backoff until we hit the configured retry cap. + } finally { + state.inFlight = false; + } + + if (isDecryptionFailure(state.event)) { + this.scheduleDecryptRetry(state); + return; + } + + this.clearDecryptRetry(retryKey); + } + + private clearDecryptRetry(retryKey: string): void { + const state = this.decryptRetries.get(retryKey); + if (state?.timer) { + clearTimeout(state.timer); + } + this.decryptRetries.delete(retryKey); + this.failedDecryptionsNotified.delete(retryKey); + } + + private rememberDecryptedMessage(roomId: string, eventId: string): void { + if (!eventId) { + return; + } + const now = Date.now(); + this.pruneDecryptedMessageDedupe(now); + this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now); + } + + private pruneDecryptedMessageDedupe(now: number): void { + const ttlMs = 30_000; + for (const [key, createdAt] of this.decryptedMessageDedupe) { + if (now - createdAt > ttlMs) { + this.decryptedMessageDedupe.delete(key); + } + } + const maxEntries = 2048; + while (this.decryptedMessageDedupe.size > maxEntries) { + const oldest = this.decryptedMessageDedupe.keys().next().value; + if (oldest === undefined) { + break; + } + this.decryptedMessageDedupe.delete(oldest); + } + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts new file mode 100644 index 00000000000..b3fff8fc52b --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/event-helpers.test.ts @@ -0,0 +1,60 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import { describe, expect, it } from "vitest"; +import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js"; + +describe("event-helpers", () => { + it("parses mxc URIs", () => { + expect(parseMxc("mxc://server.example/media-id")).toEqual({ + server: "server.example", + mediaId: "media-id", + }); + expect(parseMxc("not-mxc")).toBeNull(); + }); + + it("builds HTTP errors from JSON and plain text payloads", () => { + const fromJson = buildHttpError(403, JSON.stringify({ error: "forbidden" })); + expect(fromJson.message).toBe("forbidden"); + expect(fromJson.statusCode).toBe(403); + + const fromText = buildHttpError(500, "internal failure"); + expect(fromText.message).toBe("internal failure"); + expect(fromText.statusCode).toBe(500); + }); + + it("serializes Matrix events and resolves state key from available sources", () => { + const viaGetter = { + getId: () => "$1", + getSender: () => "@alice:example.org", + getType: () => "m.room.member", + getTs: () => 1000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({ age: 1 }), + getStateKey: () => "@alice:example.org", + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org"); + + const viaWire = { + getId: () => "$2", + getSender: () => "@bob:example.org", + getType: () => "m.room.member", + getTs: () => 2000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + getWireContent: () => ({ state_key: "@bob:example.org" }), + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org"); + + const viaRaw = { + getId: () => "$3", + getSender: () => "@carol:example.org", + getType: () => "m.room.member", + getTs: () => 3000, + getContent: () => ({ membership: "join" }), + getUnsigned: () => ({}), + getStateKey: () => undefined, + event: { state_key: "@carol:example.org" }, + } as unknown as MatrixEvent; + expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org"); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/event-helpers.ts b/extensions/matrix-js/src/matrix/sdk/event-helpers.ts new file mode 100644 index 00000000000..b9e62f3a944 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/event-helpers.ts @@ -0,0 +1,71 @@ +import type { MatrixEvent } from "matrix-js-sdk"; +import type { MatrixRawEvent } from "./types.js"; + +export function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent { + const unsigned = (event.getUnsigned?.() ?? {}) as { + age?: number; + redacted_because?: unknown; + }; + const raw: MatrixRawEvent = { + event_id: event.getId() ?? "", + sender: event.getSender() ?? "", + type: event.getType() ?? "", + origin_server_ts: event.getTs() ?? 0, + content: ((event.getContent?.() ?? {}) as Record) || {}, + unsigned, + }; + const stateKey = resolveMatrixStateKey(event); + if (typeof stateKey === "string") { + raw.state_key = stateKey; + } + return raw; +} + +export function parseMxc(url: string): { server: string; mediaId: string } | null { + const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); + if (!match) { + return null; + } + return { + server: match[1], + mediaId: match[2], + }; +} + +export function buildHttpError( + statusCode: number, + bodyText: string, +): Error & { statusCode: number } { + let message = `Matrix HTTP ${statusCode}`; + if (bodyText.trim()) { + try { + const parsed = JSON.parse(bodyText) as { error?: string }; + if (typeof parsed.error === "string" && parsed.error.trim()) { + message = parsed.error.trim(); + } else { + message = bodyText.slice(0, 500); + } + } catch { + message = bodyText.slice(0, 500); + } + } + return Object.assign(new Error(message), { statusCode }); +} + +function resolveMatrixStateKey(event: MatrixEvent): string | undefined { + const direct = event.getStateKey?.(); + if (typeof direct === "string") { + return direct; + } + const wireContent = ( + event as { getWireContent?: () => { state_key?: unknown } } + ).getWireContent?.(); + if (wireContent && typeof wireContent.state_key === "string") { + return wireContent.state_key; + } + const rawEvent = (event as { event?: { state_key?: unknown } }).event; + if (rawEvent && typeof rawEvent.state_key === "string") { + return rawEvent.state_key; + } + return undefined; +} diff --git a/extensions/matrix-js/src/matrix/sdk/http-client.test.ts b/extensions/matrix-js/src/matrix/sdk/http-client.test.ts new file mode 100644 index 00000000000..f2b7ed59ee6 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/http-client.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { performMatrixRequestMock } = vi.hoisted(() => ({ + performMatrixRequestMock: vi.fn(), +})); + +vi.mock("./transport.js", () => ({ + performMatrixRequest: performMatrixRequestMock, +})); + +import { MatrixAuthedHttpClient } from "./http-client.js"; + +describe("MatrixAuthedHttpClient", () => { + beforeEach(() => { + performMatrixRequestMock.mockReset(); + }); + + it("parses JSON responses and forwards absolute-endpoint opt-in", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + text: '{"ok":true}', + buffer: Buffer.from('{"ok":true}', "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + timeoutMs: 5000, + allowAbsoluteEndpoint: true, + }); + + expect(result).toEqual({ ok: true }); + expect(performMatrixRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + endpoint: "https://matrix.example.org/_matrix/client/v3/account/whoami", + allowAbsoluteEndpoint: true, + }), + ); + }); + + it("returns plain text when response is not JSON", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response("pong", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + text: "pong", + buffer: Buffer.from("pong", "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/ping", + timeoutMs: 5000, + }); + + expect(result).toBe("pong"); + }); + + it("returns raw buffers for media requests", async () => { + const payload = Buffer.from([1, 2, 3, 4]); + performMatrixRequestMock.mockResolvedValue({ + response: new Response(payload, { status: 200 }), + text: payload.toString("utf8"), + buffer: payload, + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + const result = await client.requestRaw({ + method: "GET", + endpoint: "/_matrix/media/v3/download/example/id", + timeoutMs: 5000, + }); + + expect(result).toEqual(payload); + }); + + it("raises HTTP errors with status code metadata", async () => { + performMatrixRequestMock.mockResolvedValue({ + response: new Response(JSON.stringify({ error: "forbidden" }), { + status: 403, + headers: { "content-type": "application/json" }, + }), + text: JSON.stringify({ error: "forbidden" }), + buffer: Buffer.from(JSON.stringify({ error: "forbidden" }), "utf8"), + }); + + const client = new MatrixAuthedHttpClient("https://matrix.example.org", "token"); + await expect( + client.requestJson({ + method: "GET", + endpoint: "/_matrix/client/v3/rooms", + timeoutMs: 5000, + }), + ).rejects.toMatchObject({ + message: "forbidden", + statusCode: 403, + }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/http-client.ts b/extensions/matrix-js/src/matrix/sdk/http-client.ts new file mode 100644 index 00000000000..d047bcc9c77 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/http-client.ts @@ -0,0 +1,63 @@ +import { buildHttpError } from "./event-helpers.js"; +import { type HttpMethod, type QueryParams, performMatrixRequest } from "./transport.js"; + +export class MatrixAuthedHttpClient { + constructor( + private readonly homeserver: string, + private readonly accessToken: string, + ) {} + + async requestJson(params: { + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + body?: unknown; + timeoutMs: number; + allowAbsoluteEndpoint?: boolean; + }): Promise { + const { response, text } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, + method: params.method, + endpoint: params.endpoint, + qs: params.qs, + body: params.body, + timeoutMs: params.timeoutMs, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, + }); + if (!response.ok) { + throw buildHttpError(response.status, text); + } + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + if (!text.trim()) { + return {}; + } + return JSON.parse(text); + } + return text; + } + + async requestRaw(params: { + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + timeoutMs: number; + allowAbsoluteEndpoint?: boolean; + }): Promise { + const { response, buffer } = await performMatrixRequest({ + homeserver: this.homeserver, + accessToken: this.accessToken, + method: params.method, + endpoint: params.endpoint, + qs: params.qs, + timeoutMs: params.timeoutMs, + raw: true, + allowAbsoluteEndpoint: params.allowAbsoluteEndpoint, + }); + if (!response.ok) { + throw buildHttpError(response.status, buffer.toString("utf8")); + } + return buffer; + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts b/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts new file mode 100644 index 00000000000..d6e355ca212 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/idb-persistence.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import path from "node:path"; +import { indexedDB as fakeIndexedDB } from "fake-indexeddb"; +import { LogService } from "./logger.js"; + +type IdbStoreSnapshot = { + name: string; + keyPath: IDBObjectStoreParameters["keyPath"]; + autoIncrement: boolean; + indexes: { name: string; keyPath: string | string[]; multiEntry: boolean; unique: boolean }[]; + records: { key: IDBValidKey; value: unknown }[]; +}; + +type IdbDatabaseSnapshot = { + name: string; + version: number; + stores: IdbStoreSnapshot[]; +}; + +function idbReq(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function dumpIndexedDatabases(databasePrefix?: string): Promise { + const idb = fakeIndexedDB; + const dbList = await idb.databases(); + const snapshot: IdbDatabaseSnapshot[] = []; + const expectedPrefix = databasePrefix ? `${databasePrefix}::` : null; + + for (const { name, version } of dbList) { + if (!name || !version) continue; + if (expectedPrefix && !name.startsWith(expectedPrefix)) continue; + const db: IDBDatabase = await new Promise((resolve, reject) => { + const r = idb.open(name, version); + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + + const stores: IdbStoreSnapshot[] = []; + for (const storeName of db.objectStoreNames) { + const tx = db.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const storeInfo: IdbStoreSnapshot = { + name: storeName, + keyPath: store.keyPath as IDBObjectStoreParameters["keyPath"], + autoIncrement: store.autoIncrement, + indexes: [], + records: [], + }; + for (const idxName of store.indexNames) { + const idx = store.index(idxName); + storeInfo.indexes.push({ + name: idxName, + keyPath: idx.keyPath as string | string[], + multiEntry: idx.multiEntry, + unique: idx.unique, + }); + } + const keys = await idbReq(store.getAllKeys()); + const values = await idbReq(store.getAll()); + storeInfo.records = keys.map((k, i) => ({ key: k, value: values[i] })); + stores.push(storeInfo); + } + snapshot.push({ name, version, stores }); + db.close(); + } + return snapshot; +} + +async function restoreIndexedDatabases(snapshot: IdbDatabaseSnapshot[]): Promise { + const idb = fakeIndexedDB; + for (const dbSnap of snapshot) { + await new Promise((resolve, reject) => { + const r = idb.open(dbSnap.name, dbSnap.version); + r.onupgradeneeded = () => { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + const opts: IDBObjectStoreParameters = {}; + if (storeSnap.keyPath !== null) opts.keyPath = storeSnap.keyPath; + if (storeSnap.autoIncrement) opts.autoIncrement = true; + const store = db.createObjectStore(storeSnap.name, opts); + for (const idx of storeSnap.indexes) { + store.createIndex(idx.name, idx.keyPath, { + unique: idx.unique, + multiEntry: idx.multiEntry, + }); + } + } + }; + r.onsuccess = async () => { + try { + const db = r.result; + for (const storeSnap of dbSnap.stores) { + if (storeSnap.records.length === 0) continue; + const tx = db.transaction(storeSnap.name, "readwrite"); + const store = tx.objectStore(storeSnap.name); + for (const rec of storeSnap.records) { + if (storeSnap.keyPath !== null) { + store.put(rec.value); + } else { + store.put(rec.value, rec.key); + } + } + await new Promise((res) => { + tx.oncomplete = () => res(); + }); + } + db.close(); + resolve(); + } catch (err) { + reject(err); + } + }; + r.onerror = () => reject(r.error); + }); + } +} + +function resolveDefaultIdbSnapshotPath(): string { + const stateDir = + process.env.OPENCLAW_STATE_DIR || + process.env.MOLTBOT_STATE_DIR || + path.join(process.env.HOME || "/tmp", ".openclaw"); + return path.join(stateDir, "credentials", "matrix-js", "crypto-idb-snapshot.json"); +} + +export async function restoreIdbFromDisk(snapshotPath?: string): Promise { + const resolvedPath = snapshotPath ?? resolveDefaultIdbSnapshotPath(); + try { + const data = fs.readFileSync(resolvedPath, "utf8"); + const snapshot: IdbDatabaseSnapshot[] = JSON.parse(data); + if (!Array.isArray(snapshot) || snapshot.length === 0) return false; + await restoreIndexedDatabases(snapshot); + LogService.info( + "IdbPersistence", + `Restored ${snapshot.length} IndexedDB database(s) from ${resolvedPath}`, + ); + return true; + } catch { + return false; + } +} + +export async function persistIdbToDisk(params?: { + snapshotPath?: string; + databasePrefix?: string; +}): Promise { + const snapshotPath = params?.snapshotPath ?? resolveDefaultIdbSnapshotPath(); + try { + const snapshot = await dumpIndexedDatabases(params?.databasePrefix); + if (snapshot.length === 0) return; + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot)); + LogService.debug( + "IdbPersistence", + `Persisted ${snapshot.length} IndexedDB database(s) to ${snapshotPath}`, + ); + } catch (err) { + LogService.warn("IdbPersistence", "Failed to persist IndexedDB snapshot:", err); + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/logger.ts b/extensions/matrix-js/src/matrix/sdk/logger.ts new file mode 100644 index 00000000000..61831a37815 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/logger.ts @@ -0,0 +1,98 @@ +import { format } from "node:util"; +import type { RuntimeLogger } from "openclaw/plugin-sdk/matrix-js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export type Logger = { + trace: (module: string, ...messageOrObject: unknown[]) => void; + debug: (module: string, ...messageOrObject: unknown[]) => void; + info: (module: string, ...messageOrObject: unknown[]) => void; + warn: (module: string, ...messageOrObject: unknown[]) => void; + error: (module: string, ...messageOrObject: unknown[]) => void; +}; + +export function noop(): void { + // no-op +} + +function resolveRuntimeLogger(module: string): RuntimeLogger | null { + try { + return getMatrixRuntime().logging.getChildLogger({ module: `matrix-js:${module}` }); + } catch { + return null; + } +} + +function formatMessage(module: string, messageOrObject: unknown[]): string { + if (messageOrObject.length === 0) { + return `[${module}]`; + } + return `[${module}] ${format(...messageOrObject)}`; +} + +export class ConsoleLogger { + private emit( + level: "debug" | "info" | "warn" | "error", + module: string, + ...messageOrObject: unknown[] + ): void { + const runtimeLogger = resolveRuntimeLogger(module); + const message = formatMessage(module, messageOrObject); + if (runtimeLogger) { + if (level === "debug") { + runtimeLogger.debug?.(message); + return; + } + runtimeLogger[level](message); + return; + } + if (level === "debug") { + console.debug(message); + return; + } + console[level](message); + } + + trace(module: string, ...messageOrObject: unknown[]): void { + this.emit("debug", module, ...messageOrObject); + } + + debug(module: string, ...messageOrObject: unknown[]): void { + this.emit("debug", module, ...messageOrObject); + } + + info(module: string, ...messageOrObject: unknown[]): void { + this.emit("info", module, ...messageOrObject); + } + + warn(module: string, ...messageOrObject: unknown[]): void { + this.emit("warn", module, ...messageOrObject); + } + + error(module: string, ...messageOrObject: unknown[]): void { + this.emit("error", module, ...messageOrObject); + } +} + +const defaultLogger = new ConsoleLogger(); +let activeLogger: Logger = defaultLogger; + +export const LogService = { + setLogger(logger: Logger): void { + activeLogger = logger; + }, + trace(module: string, ...messageOrObject: unknown[]): void { + activeLogger.trace(module, ...messageOrObject); + }, + debug(module: string, ...messageOrObject: unknown[]): void { + activeLogger.debug(module, ...messageOrObject); + }, + info(module: string, ...messageOrObject: unknown[]): void { + activeLogger.info(module, ...messageOrObject); + }, + warn(module: string, ...messageOrObject: unknown[]): void { + activeLogger.warn(module, ...messageOrObject); + }, + error(module: string, ...messageOrObject: unknown[]): void { + activeLogger.error(module, ...messageOrObject); + }, +}; diff --git a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts new file mode 100644 index 00000000000..060ad8d989d --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { encodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import type { MatrixCryptoBootstrapApi } from "./types.js"; + +function createTempRecoveryKeyPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-recovery-key-store-")); + return path.join(dir, "recovery-key.json"); +} + +describe("MatrixRecoveryKeyStore", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("loads a stored recovery key for requested secret-storage keys", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "SSSS", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + const resolved = await callbacks.getSecretStorageKey?.( + { keys: { SSSS: { name: "test" } } }, + "m.cross_signing.master", + ); + + expect(resolved?.[0]).toBe("SSSS"); + expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]); + }); + + it("persists cached secret-storage keys with secure file permissions", () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const callbacks = store.buildCryptoCallbacks(); + + callbacks.cacheSecretStorageKey?.( + "KEY123", + { + name: "openclaw", + }, + new Uint8Array([9, 8, 7]), + ); + + const saved = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + keyId?: string; + privateKeyBase64?: string; + }; + expect(saved.keyId).toBe("KEY123"); + expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64")); + + const mode = fs.statSync(recoveryKeyPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("creates and persists a recovery key when secret storage is missing", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "GENERATED", + keyInfo: { name: "generated" }, + privateKey: new Uint8Array([5, 6, 7, 8]), + encodedPrivateKey: "encoded-generated-key", + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: null })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "GENERATED", + encodedPrivateKey: "encoded-generated-key", + }); + }); + + it("rebinds stored recovery key to server default key id when it changes", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + fs.writeFileSync( + recoveryKeyPath, + JSON.stringify({ + version: 1, + createdAt: new Date().toISOString(), + keyId: "OLD", + privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"), + }), + "utf8", + ); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + + const bootstrapSecretStorage = vi.fn(async () => {}); + const createRecoveryKeyFromPassphrase = vi.fn(async () => { + throw new Error("should not be called"); + }); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "NEW" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).not.toHaveBeenCalled(); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "NEW", + }); + }); + + it("recreates secret storage when default key exists but is not usable locally", async () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const generated = { + keyId: "RECOVERED", + keyInfo: { name: "recovered" }, + privateKey: new Uint8Array([1, 1, 2, 3]), + encodedPrivateKey: "encoded-recovered-key", + }; + const createRecoveryKeyFromPassphrase = vi.fn(async () => generated); + const bootstrapSecretStorage = vi.fn( + async (opts?: { createSecretStorageKey?: () => Promise }) => { + await opts?.createSecretStorageKey?.(); + }, + ); + const crypto = { + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage, + createRecoveryKeyFromPassphrase, + getSecretStorageStatus: vi.fn(async () => ({ ready: false, defaultKeyId: "LEGACY" })), + requestOwnUserVerification: vi.fn(async () => null), + } as unknown as MatrixCryptoBootstrapApi; + + await store.bootstrapSecretStorageWithRecoveryKey(crypto); + + expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1); + expect(bootstrapSecretStorage).toHaveBeenCalledWith( + expect.objectContaining({ + setupNewSecretStorage: true, + }), + ); + expect(store.getRecoveryKeySummary()).toMatchObject({ + keyId: "RECOVERED", + encodedPrivateKey: "encoded-recovered-key", + }); + }); + + it("stores an encoded recovery key and decodes its private key material", () => { + const recoveryKeyPath = createTempRecoveryKeyPath(); + const store = new MatrixRecoveryKeyStore(recoveryKeyPath); + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + expect(encoded).toBeTypeOf("string"); + + const summary = store.storeEncodedRecoveryKey({ + encodedPrivateKey: encoded as string, + keyId: "SSSSKEY", + }); + + expect(summary.keyId).toBe("SSSSKEY"); + expect(summary.encodedPrivateKey).toBe(encoded); + const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as { + privateKeyBase64?: string; + keyId?: string; + }; + expect(persisted.keyId).toBe("SSSSKEY"); + expect( + Buffer.from(persisted.privateKeyBase64 ?? "", "base64").equals( + Buffer.from(Array.from({ length: 32 }, (_, i) => i + 1)), + ), + ).toBe(true); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts new file mode 100644 index 00000000000..dab24e4075e --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import path from "node:path"; +import { decodeRecoveryKey } from "matrix-js-sdk/lib/crypto-api/recovery-key.js"; +import { LogService } from "./logger.js"; +import type { + MatrixCryptoBootstrapApi, + MatrixCryptoCallbacks, + MatrixGeneratedSecretStorageKey, + MatrixSecretStorageStatus, + MatrixStoredRecoveryKey, +} from "./types.js"; + +export class MatrixRecoveryKeyStore { + private readonly secretStorageKeyCache = new Map< + string, + { key: Uint8Array; keyInfo?: MatrixStoredRecoveryKey["keyInfo"] } + >(); + + constructor(private readonly recoveryKeyPath?: string) {} + + buildCryptoCallbacks(): MatrixCryptoCallbacks { + return { + getSecretStorageKey: async ({ keys }) => { + const requestedKeyIds = Object.keys(keys ?? {}); + if (requestedKeyIds.length === 0) { + return null; + } + + for (const keyId of requestedKeyIds) { + const cached = this.secretStorageKeyCache.get(keyId); + if (cached) { + return [keyId, new Uint8Array(cached.key)]; + } + } + + const stored = this.loadStoredRecoveryKey(); + if (!stored || !stored.privateKeyBase64) { + return null; + } + const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64")); + if (privateKey.length === 0) { + return null; + } + + if (stored.keyId && requestedKeyIds.includes(stored.keyId)) { + this.rememberSecretStorageKey(stored.keyId, privateKey, stored.keyInfo); + return [stored.keyId, privateKey]; + } + + const firstRequestedKeyId = requestedKeyIds[0]; + if (!firstRequestedKeyId) { + return null; + } + this.rememberSecretStorageKey(firstRequestedKeyId, privateKey, stored.keyInfo); + return [firstRequestedKeyId, privateKey]; + }, + cacheSecretStorageKey: (keyId, keyInfo, key) => { + const privateKey = new Uint8Array(key); + const normalizedKeyInfo: MatrixStoredRecoveryKey["keyInfo"] = { + passphrase: keyInfo?.passphrase, + name: typeof keyInfo?.name === "string" ? keyInfo.name : undefined, + }; + this.rememberSecretStorageKey(keyId, privateKey, normalizedKeyInfo); + + const stored = this.loadStoredRecoveryKey(); + this.saveRecoveryKeyToDisk({ + keyId, + keyInfo: normalizedKeyInfo, + privateKey, + encodedPrivateKey: stored?.encodedPrivateKey, + }); + }, + }; + } + + getRecoveryKeySummary(): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } | null { + const stored = this.loadStoredRecoveryKey(); + if (!stored) { + return null; + } + return { + encodedPrivateKey: stored.encodedPrivateKey, + keyId: stored.keyId, + createdAt: stored.createdAt, + }; + } + + storeEncodedRecoveryKey(params: { + encodedPrivateKey: string; + keyId?: string | null; + keyInfo?: MatrixStoredRecoveryKey["keyInfo"]; + }): { + encodedPrivateKey?: string; + keyId?: string | null; + createdAt?: string; + } { + const encodedPrivateKey = params.encodedPrivateKey.trim(); + if (!encodedPrivateKey) { + throw new Error("Matrix recovery key is required"); + } + let privateKey: Uint8Array; + try { + privateKey = decodeRecoveryKey(encodedPrivateKey); + } catch (err) { + throw new Error( + `Invalid Matrix recovery key: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const normalizedKeyId = + typeof params.keyId === "string" && params.keyId.trim() ? params.keyId.trim() : null; + const keyInfo = params.keyInfo ?? this.loadStoredRecoveryKey()?.keyInfo; + this.saveRecoveryKeyToDisk({ + keyId: normalizedKeyId, + keyInfo, + privateKey, + encodedPrivateKey, + }); + if (normalizedKeyId) { + this.rememberSecretStorageKey(normalizedKeyId, privateKey, keyInfo); + } + return this.getRecoveryKeySummary() ?? {}; + } + + async bootstrapSecretStorageWithRecoveryKey( + crypto: MatrixCryptoBootstrapApi, + options: { setupNewKeyBackup?: boolean } = {}, + ): Promise { + let status: MatrixSecretStorageStatus | null = null; + if (typeof crypto.getSecretStorageStatus === "function") { + try { + status = await crypto.getSecretStorageStatus(); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err); + } + } + + const hasDefaultSecretStorageKey = Boolean(status?.defaultKeyId); + const hasKnownInvalidSecrets = Object.values(status?.secretStorageKeyValidityMap ?? {}).some( + (valid) => valid === false, + ); + let generatedRecoveryKey = false; + const storedRecovery = this.loadStoredRecoveryKey(); + let recoveryKey: MatrixGeneratedSecretStorageKey | null = storedRecovery + ? { + keyInfo: storedRecovery.keyInfo, + privateKey: new Uint8Array(Buffer.from(storedRecovery.privateKeyBase64, "base64")), + encodedPrivateKey: storedRecovery.encodedPrivateKey, + } + : null; + + if (recoveryKey && status?.defaultKeyId) { + const defaultKeyId = status.defaultKeyId; + this.rememberSecretStorageKey(defaultKeyId, recoveryKey.privateKey, recoveryKey.keyInfo); + if (storedRecovery?.keyId !== defaultKeyId) { + this.saveRecoveryKeyToDisk({ + keyId: defaultKeyId, + keyInfo: recoveryKey.keyInfo, + privateKey: recoveryKey.privateKey, + encodedPrivateKey: recoveryKey.encodedPrivateKey, + }); + } + } + + const ensureRecoveryKey = async (): Promise => { + if (recoveryKey) { + return recoveryKey; + } + if (typeof crypto.createRecoveryKeyFromPassphrase !== "function") { + throw new Error( + "Matrix crypto backend does not support recovery key generation (createRecoveryKeyFromPassphrase missing)", + ); + } + recoveryKey = await crypto.createRecoveryKeyFromPassphrase(); + this.saveRecoveryKeyToDisk(recoveryKey); + generatedRecoveryKey = true; + return recoveryKey; + }; + + const shouldRecreateSecretStorage = + !hasDefaultSecretStorageKey || + (!recoveryKey && status?.ready === false) || + hasKnownInvalidSecrets; + + if (hasKnownInvalidSecrets) { + // Existing secret storage keys can't decrypt required secrets. Generate a fresh recovery key. + recoveryKey = null; + } + + const secretStorageOptions: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + } = { + setupNewKeyBackup: options.setupNewKeyBackup === true, + }; + + if (shouldRecreateSecretStorage) { + secretStorageOptions.setupNewSecretStorage = true; + secretStorageOptions.createSecretStorageKey = ensureRecoveryKey; + } + + await crypto.bootstrapSecretStorage(secretStorageOptions); + + if (generatedRecoveryKey && this.recoveryKeyPath) { + LogService.warn( + "MatrixClientLite", + `Generated Matrix recovery key and saved it to ${this.recoveryKeyPath}. Keep this file secure.`, + ); + } + } + + private rememberSecretStorageKey( + keyId: string, + key: Uint8Array, + keyInfo?: MatrixStoredRecoveryKey["keyInfo"], + ): void { + if (!keyId.trim()) { + return; + } + this.secretStorageKeyCache.set(keyId, { + key: new Uint8Array(key), + keyInfo, + }); + } + + private loadStoredRecoveryKey(): MatrixStoredRecoveryKey | null { + if (!this.recoveryKeyPath) { + return null; + } + try { + if (!fs.existsSync(this.recoveryKeyPath)) { + return null; + } + const raw = fs.readFileSync(this.recoveryKeyPath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + typeof parsed.createdAt !== "string" || + typeof parsed.privateKeyBase64 !== "string" || + !parsed.privateKeyBase64.trim() + ) { + return null; + } + return { + version: 1, + createdAt: parsed.createdAt, + keyId: typeof parsed.keyId === "string" ? parsed.keyId : null, + encodedPrivateKey: + typeof parsed.encodedPrivateKey === "string" ? parsed.encodedPrivateKey : undefined, + privateKeyBase64: parsed.privateKeyBase64, + keyInfo: + parsed.keyInfo && typeof parsed.keyInfo === "object" + ? { + passphrase: parsed.keyInfo.passphrase, + name: typeof parsed.keyInfo.name === "string" ? parsed.keyInfo.name : undefined, + } + : undefined, + }; + } catch { + return null; + } + } + + private saveRecoveryKeyToDisk(params: MatrixGeneratedSecretStorageKey): void { + if (!this.recoveryKeyPath) { + return; + } + try { + const payload: MatrixStoredRecoveryKey = { + version: 1, + createdAt: new Date().toISOString(), + keyId: typeof params.keyId === "string" ? params.keyId : null, + encodedPrivateKey: params.encodedPrivateKey, + privateKeyBase64: Buffer.from(params.privateKey).toString("base64"), + keyInfo: params.keyInfo + ? { + passphrase: params.keyInfo.passphrase, + name: params.keyInfo.name, + } + : undefined, + }; + fs.mkdirSync(path.dirname(this.recoveryKeyPath), { recursive: true }); + fs.writeFileSync(this.recoveryKeyPath, JSON.stringify(payload, null, 2), "utf8"); + fs.chmodSync(this.recoveryKeyPath, 0o600); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to persist recovery key:", err); + } + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/transport.ts b/extensions/matrix-js/src/matrix/sdk/transport.ts new file mode 100644 index 00000000000..8b2a7f8899d --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/transport.ts @@ -0,0 +1,171 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +type QueryValue = + | string + | number + | boolean + | null + | undefined + | Array; + +export type QueryParams = Record | null | undefined; + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint) { + return "/"; + } + return endpoint.startsWith("/") ? endpoint : `/${endpoint}`; +} + +function applyQuery(url: URL, qs: QueryParams): void { + if (!qs) { + return; + } + for (const [key, rawValue] of Object.entries(qs)) { + if (rawValue === undefined || rawValue === null) { + continue; + } + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (item === undefined || item === null) { + continue; + } + url.searchParams.append(key, String(item)); + } + continue; + } + url.searchParams.set(key, String(rawValue)); + } +} + +function isRedirectStatus(statusCode: number): boolean { + return statusCode >= 300 && statusCode < 400; +} + +async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise { + let currentUrl = new URL(url.toString()); + let method = (init.method ?? "GET").toUpperCase(); + let body = init.body; + let headers = new Headers(init.headers ?? {}); + const maxRedirects = 5; + + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { + const response = await fetch(currentUrl, { + ...init, + method, + body, + headers, + redirect: "manual", + }); + + if (!isRedirectStatus(response.status)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`); + } + + const nextUrl = new URL(location, currentUrl); + if (nextUrl.protocol !== currentUrl.protocol) { + throw new Error( + `Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`, + ); + } + + if (nextUrl.origin !== currentUrl.origin) { + headers = new Headers(headers); + headers.delete("authorization"); + } + + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && + method !== "GET" && + method !== "HEAD") + ) { + method = "GET"; + body = undefined; + headers = new Headers(headers); + headers.delete("content-type"); + headers.delete("content-length"); + } + + currentUrl = nextUrl; + } + + throw new Error(`Too many redirects while requesting ${url.toString()}`); +} + +export async function performMatrixRequest(params: { + homeserver: string; + accessToken: string; + method: HttpMethod; + endpoint: string; + qs?: QueryParams; + body?: unknown; + timeoutMs: number; + raw?: boolean; + allowAbsoluteEndpoint?: boolean; +}): Promise<{ response: Response; text: string; buffer: Buffer }> { + const isAbsoluteEndpoint = + params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://"); + if (isAbsoluteEndpoint && params.allowAbsoluteEndpoint !== true) { + throw new Error( + `Absolute Matrix endpoint is blocked by default: ${params.endpoint}. Set allowAbsoluteEndpoint=true to opt in.`, + ); + } + + const baseUrl = isAbsoluteEndpoint + ? new URL(params.endpoint) + : new URL(normalizeEndpoint(params.endpoint), params.homeserver); + applyQuery(baseUrl, params.qs); + + const headers = new Headers(); + headers.set("Accept", params.raw ? "*/*" : "application/json"); + if (params.accessToken) { + headers.set("Authorization", `Bearer ${params.accessToken}`); + } + + let body: BodyInit | undefined; + if (params.body !== undefined) { + if ( + params.body instanceof Uint8Array || + params.body instanceof ArrayBuffer || + typeof params.body === "string" + ) { + body = params.body as BodyInit; + } else { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(params.body); + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + try { + const response = await fetchWithSafeRedirects(baseUrl, { + method: params.method, + headers, + body, + signal: controller.signal, + }); + if (params.raw) { + const bytes = Buffer.from(await response.arrayBuffer()); + return { + response, + text: bytes.toString("utf8"), + buffer: bytes, + }; + } + const text = await response.text(); + return { + response, + text, + buffer: Buffer.from(text, "utf8"), + }; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/extensions/matrix-js/src/matrix/sdk/types.ts b/extensions/matrix-js/src/matrix/sdk/types.ts new file mode 100644 index 00000000000..7a7330bfa48 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/types.ts @@ -0,0 +1,217 @@ +import type { MatrixVerificationRequestLike } from "./verification-manager.js"; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; + state_key?: string; +}; + +export type MatrixClientEventMap = { + "room.event": [roomId: string, event: MatrixRawEvent]; + "room.message": [roomId: string, event: MatrixRawEvent]; + "room.encrypted_event": [roomId: string, event: MatrixRawEvent]; + "room.decrypted_event": [roomId: string, event: MatrixRawEvent]; + "room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error]; + "room.invite": [roomId: string, event: MatrixRawEvent]; + "room.join": [roomId: string, event: MatrixRawEvent]; +}; + +export type EncryptedFile = { + url: string; + key: { + kty: string; + key_ops: string[]; + alg: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: Record; + v: string; +}; + +export type FileWithThumbnailInfo = { + size?: number; + mimetype?: string; + thumbnail_url?: string; + thumbnail_info?: { + w?: number; + h?: number; + mimetype?: string; + size?: number; + }; +}; + +export type DimensionalFileInfo = FileWithThumbnailInfo & { + w?: number; + h?: number; +}; + +export type TimedFileInfo = FileWithThumbnailInfo & { + duration?: number; +}; + +export type VideoFileInfo = DimensionalFileInfo & + TimedFileInfo & { + duration?: number; + }; + +export type MessageEventContent = { + msgtype?: string; + body?: string; + format?: string; + formatted_body?: string; + filename?: string; + url?: string; + file?: EncryptedFile; + info?: Record; + "m.relates_to"?: Record; + "m.new_content"?: unknown; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; + [key: string]: unknown; +}; + +export type TextualMessageEventContent = MessageEventContent & { + msgtype: string; + body: string; +}; + +export type LocationMessageEventContent = MessageEventContent & { + msgtype?: string; + geo_uri?: string; +}; + +export type MatrixSecretStorageStatus = { + ready: boolean; + defaultKeyId: string | null; + secretStorageKeyValidityMap?: Record; +}; + +export type MatrixGeneratedSecretStorageKey = { + keyId?: string | null; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; + privateKey: Uint8Array; + encodedPrivateKey?: string; +}; + +export type MatrixDeviceVerificationStatusLike = { + isVerified?: () => boolean; + localVerified?: boolean; + crossSigningVerified?: boolean; + signedByOwner?: boolean; +}; + +export type MatrixKeyBackupInfo = { + algorithm: string; + auth_data: Record; + count?: number; + etag?: string; + version?: string; +}; + +export type MatrixKeyBackupTrustInfo = { + trusted: boolean; + matchesDecryptionKey: boolean; +}; + +export type MatrixRoomKeyBackupRestoreResult = { + total: number; + imported: number; +}; + +export type MatrixImportRoomKeyProgress = { + stage: string; + successes?: number; + failures?: number; + total?: number; +}; + +export type MatrixSecretStorageKeyDescription = { + passphrase?: unknown; + name?: string; + [key: string]: unknown; +}; + +export type MatrixCryptoCallbacks = { + getSecretStorageKey?: ( + params: { keys: Record }, + name: string, + ) => Promise<[string, Uint8Array] | null>; + cacheSecretStorageKey?: ( + keyId: string, + keyInfo: MatrixSecretStorageKeyDescription, + key: Uint8Array, + ) => void; +}; + +export type MatrixStoredRecoveryKey = { + version: 1; + createdAt: string; + keyId?: string | null; + encodedPrivateKey?: string; + privateKeyBase64: string; + keyInfo?: { + passphrase?: unknown; + name?: string; + }; +}; + +export type MatrixAuthDict = Record; + +export type MatrixUiAuthCallback = ( + makeRequest: (authData: MatrixAuthDict | null) => Promise, +) => Promise; + +export type MatrixCryptoBootstrapApi = { + on: (eventName: string, listener: (...args: unknown[]) => void) => void; + bootstrapCrossSigning: (opts: { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?: MatrixUiAuthCallback; + }) => Promise; + bootstrapSecretStorage: (opts?: { + createSecretStorageKey?: () => Promise; + setupNewSecretStorage?: boolean; + setupNewKeyBackup?: boolean; + }) => Promise; + createRecoveryKeyFromPassphrase?: (password?: string) => Promise; + getSecretStorageStatus?: () => Promise; + requestOwnUserVerification: () => Promise; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; + getDeviceVerificationStatus?: ( + userId: string, + deviceId: string, + ) => Promise; + getSessionBackupPrivateKey?: () => Promise; + loadSessionBackupPrivateKeyFromSecretStorage?: () => Promise; + getActiveSessionBackupVersion?: () => Promise; + getKeyBackupInfo?: () => Promise; + isKeyBackupTrusted?: (info: MatrixKeyBackupInfo) => Promise; + checkKeyBackupAndEnable?: () => Promise; + restoreKeyBackup?: (opts?: { + progressCallback?: (progress: MatrixImportRoomKeyProgress) => void; + }) => Promise; + setDeviceVerified?: (userId: string, deviceId: string, verified?: boolean) => Promise; + crossSignDevice?: (deviceId: string) => Promise; + isCrossSigningReady?: () => Promise; + userHasCrossSigningKeys?: (userId?: string, downloadUncached?: boolean) => Promise; +}; diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts new file mode 100644 index 00000000000..f88f6441a4a --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts @@ -0,0 +1,347 @@ +import { EventEmitter } from "node:events"; +import { + VerificationPhase, + VerificationRequestEvent, +} from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { describe, expect, it, vi } from "vitest"; +import { + MatrixVerificationManager, + type MatrixShowQrCodeCallbacks, + type MatrixShowSasCallbacks, + type MatrixVerificationRequestLike, + type MatrixVerifierLike, +} from "./verification-manager.js"; + +class MockVerifier extends EventEmitter implements MatrixVerifierLike { + constructor( + private readonly sasCallbacks: MatrixShowSasCallbacks | null, + private readonly qrCallbacks: MatrixShowQrCodeCallbacks | null, + private readonly verifyImpl: () => Promise = async () => {}, + ) { + super(); + } + + verify(): Promise { + return this.verifyImpl(); + } + + cancel(_e: Error): void { + void _e; + } + + getShowSasCallbacks(): MatrixShowSasCallbacks | null { + return this.sasCallbacks; + } + + getReciprocateQrCodeCallbacks(): MatrixShowQrCodeCallbacks | null { + return this.qrCallbacks; + } +} + +class MockVerificationRequest extends EventEmitter implements MatrixVerificationRequestLike { + transactionId?: string; + roomId?: string; + initiatedByMe = false; + otherUserId = "@alice:example.org"; + otherDeviceId?: string; + isSelfVerification = false; + phase = VerificationPhase.Requested; + pending = true; + accepting = false; + declining = false; + methods: string[] = ["m.sas.v1"]; + chosenMethod?: string | null; + cancellationCode?: string | null; + verifier?: MatrixVerifierLike; + + constructor(init?: Partial) { + super(); + Object.assign(this, init); + } + + accept = vi.fn(async () => { + this.phase = VerificationPhase.Ready; + }); + + cancel = vi.fn(async () => { + this.phase = VerificationPhase.Cancelled; + }); + + startVerification = vi.fn(async (_method: string) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + scanQRCode = vi.fn(async (_qrCodeData: Uint8ClampedArray) => { + if (!this.verifier) { + throw new Error("verifier not configured"); + } + this.phase = VerificationPhase.Started; + return this.verifier; + }); + + generateQRCode = vi.fn(async () => new Uint8ClampedArray([1, 2, 3])); +} + +describe("MatrixVerificationManager", () => { + it("handles rust verification requests whose methods getter throws", () => { + const manager = new MatrixVerificationManager(); + const request = new MockVerificationRequest({ + transactionId: "txn-rust-methods", + phase: VerificationPhase.Requested, + }); + Object.defineProperty(request, "methods", { + get() { + throw new Error("not implemented"); + }, + }); + + const summary = manager.trackVerificationRequest(request); + + expect(summary.id).toBeTruthy(); + expect(summary.methods).toEqual([]); + expect(summary.phase).toBe(VerificationPhase.Requested); + }); + + it("reuses the same tracked id for repeated transaction IDs", () => { + const manager = new MatrixVerificationManager(); + const first = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Requested, + }); + const second = new MockVerificationRequest({ + transactionId: "txn-1", + phase: VerificationPhase.Ready, + pending: false, + chosenMethod: "m.sas.v1", + }); + + const firstSummary = manager.trackVerificationRequest(first); + const secondSummary = manager.trackVerificationRequest(second); + + expect(secondSummary.id).toBe(firstSummary.id); + expect(secondSummary.phase).toBe(VerificationPhase.Ready); + expect(secondSummary.pending).toBe(false); + expect(secondSummary.chosenMethod).toBe("m.sas.v1"); + }); + + it("starts SAS verification and exposes SAS payload/callback flow", async () => { + const confirm = vi.fn(async () => {}); + const mismatch = vi.fn(); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "cat"], + ["dog", "dog"], + ["fox", "fox"], + ], + }, + confirm, + mismatch, + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-2", + verifier, + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + const started = await manager.startVerification(tracked.id, "sas"); + expect(started.hasSas).toBe(true); + expect(started.sas?.decimal).toEqual([111, 222, 333]); + expect(started.sas?.emoji?.length).toBe(3); + + const sas = manager.getVerificationSas(tracked.id); + expect(sas.decimal).toEqual([111, 222, 333]); + expect(sas.emoji?.length).toBe(3); + + await manager.confirmVerificationSas(tracked.id); + expect(confirm).toHaveBeenCalledTimes(2); + + manager.mismatchVerificationSas(tracked.id); + expect(mismatch).toHaveBeenCalledTimes(1); + }); + + it("auto-starts an incoming verifier exposed via request change events", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-incoming-change", + verifier: undefined, + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + request.verifier = verifier; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect(verify).toHaveBeenCalledTimes(1); + }); + const summary = manager.listVerifications().find((item) => item.id === tracked.id); + expect(summary?.hasSas).toBe(true); + expect(summary?.sas?.decimal).toEqual([6158, 1986, 3513]); + expect(manager.getVerificationSas(tracked.id).decimal).toEqual([6158, 1986, 3513]); + }); + + it("auto-starts inbound SAS when request becomes ready without a verifier", async () => { + const verify = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [1234, 5678, 9012], + emoji: [ + ["gift", "Gift"], + ["rocket", "Rocket"], + ["butterfly", "Butterfly"], + ], + }, + confirm: vi.fn(async () => {}), + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + verify, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-auto-start-sas", + initiatedByMe: false, + verifier: undefined, + }); + request.startVerification = vi.fn(async (_method: string) => { + request.phase = VerificationPhase.Started; + request.verifier = verifier; + return verifier; + }); + const manager = new MatrixVerificationManager(); + const tracked = manager.trackVerificationRequest(request); + + request.phase = VerificationPhase.Ready; + request.emit(VerificationRequestEvent.Change); + + await vi.waitFor(() => { + expect(request.startVerification).toHaveBeenCalledWith("m.sas.v1"); + }); + await vi.waitFor(() => { + expect(verify).toHaveBeenCalledTimes(1); + }); + const summary = manager.listVerifications().find((item) => item.id === tracked.id); + expect(summary?.hasSas).toBe(true); + expect(summary?.sas?.decimal).toEqual([1234, 5678, 9012]); + expect(manager.getVerificationSas(tracked.id).decimal).toEqual([1234, 5678, 9012]); + }); + + it("auto-confirms inbound SAS when callbacks are available", async () => { + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [6158, 1986, 3513], + emoji: [ + ["gift", "Gift"], + ["globe", "Globe"], + ["horse", "Horse"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-auto-confirm", + initiatedByMe: false, + verifier, + }); + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await vi.waitFor(() => { + expect(confirm).toHaveBeenCalledTimes(1); + }); + }); + + it("does not auto-confirm SAS for verifications initiated by this device", async () => { + vi.useFakeTimers(); + const confirm = vi.fn(async () => {}); + const verifier = new MockVerifier( + { + sas: { + decimal: [111, 222, 333], + emoji: [ + ["cat", "Cat"], + ["dog", "Dog"], + ["fox", "Fox"], + ], + }, + confirm, + mismatch: vi.fn(), + cancel: vi.fn(), + }, + null, + async () => {}, + ); + const request = new MockVerificationRequest({ + transactionId: "txn-no-auto-confirm", + initiatedByMe: true, + verifier, + }); + try { + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest(request); + + await vi.advanceTimersByTimeAsync(20); + expect(confirm).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("prunes stale terminal sessions during list operations", () => { + const now = new Date("2026-02-08T15:00:00.000Z").getTime(); + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValue(now); + + const manager = new MatrixVerificationManager(); + manager.trackVerificationRequest( + new MockVerificationRequest({ + transactionId: "txn-old-done", + phase: VerificationPhase.Done, + pending: false, + }), + ); + + nowSpy.mockReturnValue(now + 24 * 60 * 60 * 1000 + 1); + const summaries = manager.listVerifications(); + + expect(summaries).toHaveLength(0); + nowSpy.mockRestore(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk/verification-manager.ts b/extensions/matrix-js/src/matrix/sdk/verification-manager.ts new file mode 100644 index 00000000000..8cff8a07954 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.ts @@ -0,0 +1,586 @@ +import { + VerificationPhase, + VerificationRequestEvent, + VerifierEvent, +} from "matrix-js-sdk/lib/crypto-api/verification.js"; +import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; + +export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr"; + +export type MatrixVerificationSummary = { + id: string; + transactionId?: string; + roomId?: string; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + initiatedByMe: boolean; + phase: number; + phaseName: string; + pending: boolean; + methods: string[]; + chosenMethod?: string | null; + canAccept: boolean; + hasSas: boolean; + sas?: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + hasReciprocateQr: boolean; + completed: boolean; + error?: string; + createdAt: string; + updatedAt: string; +}; + +export type MatrixShowSasCallbacks = { + sas: { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + }; + confirm: () => Promise; + mismatch: () => void; + cancel: () => void; +}; + +export type MatrixShowQrCodeCallbacks = { + confirm: () => void; + cancel: () => void; +}; + +export type MatrixVerifierLike = { + verify: () => Promise; + cancel: (e: Error) => void; + getShowSasCallbacks: () => MatrixShowSasCallbacks | null; + getReciprocateQrCodeCallbacks: () => MatrixShowQrCodeCallbacks | null; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationRequestLike = { + transactionId?: string; + roomId?: string; + initiatedByMe: boolean; + otherUserId: string; + otherDeviceId?: string; + isSelfVerification: boolean; + phase: number; + pending: boolean; + accepting: boolean; + declining: boolean; + methods: string[]; + chosenMethod?: string | null; + cancellationCode?: string | null; + accept: () => Promise; + cancel: (params?: { reason?: string; code?: string }) => Promise; + startVerification: (method: string) => Promise; + scanQRCode: (qrCodeData: Uint8ClampedArray) => Promise; + generateQRCode: () => Promise; + verifier?: MatrixVerifierLike; + on: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +export type MatrixVerificationCryptoApi = { + requestOwnUserVerification: () => Promise; + requestDeviceVerification?: ( + userId: string, + deviceId: string, + ) => Promise; + requestVerificationDM?: ( + userId: string, + roomId: string, + ) => Promise; +}; + +type MatrixVerificationSession = { + id: string; + request: MatrixVerificationRequestLike; + createdAtMs: number; + updatedAtMs: number; + error?: string; + activeVerifier?: MatrixVerifierLike; + verifyPromise?: Promise; + verifyStarted: boolean; + startRequested: boolean; + sasAutoConfirmStarted: boolean; + sasCallbacks?: MatrixShowSasCallbacks; + reciprocateQrCallbacks?: MatrixShowQrCodeCallbacks; +}; + +const MAX_TRACKED_VERIFICATION_SESSIONS = 256; +const TERMINAL_SESSION_RETENTION_MS = 24 * 60 * 60 * 1000; + +export class MatrixVerificationManager { + private readonly verificationSessions = new Map(); + private verificationSessionCounter = 0; + private readonly trackedVerificationRequests = new WeakSet(); + private readonly trackedVerificationVerifiers = new WeakSet(); + + private readRequestValue( + request: MatrixVerificationRequestLike, + reader: () => T, + fallback: T, + ): T { + try { + return reader(); + } catch { + return fallback; + } + } + + private pruneVerificationSessions(nowMs: number): void { + for (const [id, session] of this.verificationSessions) { + const phase = this.readRequestValue(session.request, () => session.request.phase, -1); + const isTerminal = phase === VerificationPhase.Done || phase === VerificationPhase.Cancelled; + if (isTerminal && nowMs - session.updatedAtMs > TERMINAL_SESSION_RETENTION_MS) { + this.verificationSessions.delete(id); + } + } + + if (this.verificationSessions.size <= MAX_TRACKED_VERIFICATION_SESSIONS) { + return; + } + + const sortedByAge = Array.from(this.verificationSessions.entries()).sort( + (a, b) => a[1].updatedAtMs - b[1].updatedAtMs, + ); + const overflow = this.verificationSessions.size - MAX_TRACKED_VERIFICATION_SESSIONS; + for (let i = 0; i < overflow; i += 1) { + const entry = sortedByAge[i]; + if (entry) { + this.verificationSessions.delete(entry[0]); + } + } + } + + private getVerificationPhaseName(phase: number): string { + switch (phase) { + case VerificationPhase.Unsent: + return "unsent"; + case VerificationPhase.Requested: + return "requested"; + case VerificationPhase.Ready: + return "ready"; + case VerificationPhase.Started: + return "started"; + case VerificationPhase.Cancelled: + return "cancelled"; + case VerificationPhase.Done: + return "done"; + default: + return `unknown(${phase})`; + } + } + + private touchVerificationSession(session: MatrixVerificationSession): void { + session.updatedAtMs = Date.now(); + } + + private buildVerificationSummary(session: MatrixVerificationSession): MatrixVerificationSummary { + const request = session.request; + const phase = this.readRequestValue(request, () => request.phase, VerificationPhase.Requested); + const accepting = this.readRequestValue(request, () => request.accepting, false); + const declining = this.readRequestValue(request, () => request.declining, false); + const pending = this.readRequestValue(request, () => request.pending, false); + const methodsRaw = this.readRequestValue(request, () => request.methods, []); + const methods = Array.isArray(methodsRaw) + ? methodsRaw.filter((entry): entry is string => typeof entry === "string") + : []; + const sasCallbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (sasCallbacks) { + session.sasCallbacks = sasCallbacks; + } + const canAccept = phase < VerificationPhase.Ready && !accepting && !declining; + return { + id: session.id, + transactionId: this.readRequestValue(request, () => request.transactionId, undefined), + roomId: this.readRequestValue(request, () => request.roomId, undefined), + otherUserId: this.readRequestValue(request, () => request.otherUserId, "unknown"), + otherDeviceId: this.readRequestValue(request, () => request.otherDeviceId, undefined), + isSelfVerification: this.readRequestValue(request, () => request.isSelfVerification, false), + initiatedByMe: this.readRequestValue(request, () => request.initiatedByMe, false), + phase, + phaseName: this.getVerificationPhaseName(phase), + pending, + methods, + chosenMethod: this.readRequestValue(request, () => request.chosenMethod ?? null, null), + canAccept, + hasSas: Boolean(sasCallbacks), + sas: sasCallbacks + ? { + decimal: sasCallbacks.sas.decimal, + emoji: sasCallbacks.sas.emoji, + } + : undefined, + hasReciprocateQr: Boolean(session.reciprocateQrCallbacks), + completed: phase === VerificationPhase.Done, + error: session.error, + createdAt: new Date(session.createdAtMs).toISOString(), + updatedAt: new Date(session.updatedAtMs).toISOString(), + }; + } + + private findVerificationSession(id: string): MatrixVerificationSession { + const direct = this.verificationSessions.get(id); + if (direct) { + return direct; + } + for (const session of this.verificationSessions.values()) { + const txId = this.readRequestValue(session.request, () => session.request.transactionId, ""); + if (txId === id) { + return session; + } + } + throw new Error(`Matrix verification request not found: ${id}`); + } + + private ensureVerificationRequestTracked(session: MatrixVerificationSession): void { + const requestObj = session.request as unknown as object; + if (this.trackedVerificationRequests.has(requestObj)) { + return; + } + this.trackedVerificationRequests.add(requestObj); + session.request.on(VerificationRequestEvent.Change, () => { + this.touchVerificationSession(session); + const verifier = this.readRequestValue(session.request, () => session.request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(session, verifier); + } + this.maybeAutoStartInboundSas(session); + }); + } + + private maybeAutoStartInboundSas(session: MatrixVerificationSession): void { + if (session.activeVerifier || session.verifyStarted || session.startRequested) { + return; + } + if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) { + return; + } + const phase = this.readRequestValue( + session.request, + () => session.request.phase, + VerificationPhase.Requested, + ); + if (phase < VerificationPhase.Ready || phase >= VerificationPhase.Cancelled) { + return; + } + const methodsRaw = this.readRequestValue( + session.request, + () => session.request.methods, + [], + ); + const methods = Array.isArray(methodsRaw) + ? methodsRaw.filter((entry): entry is string => typeof entry === "string") + : []; + const chosenMethod = this.readRequestValue( + session.request, + () => session.request.chosenMethod, + null, + ); + const supportsSas = + methods.includes(VerificationMethod.Sas) || chosenMethod === VerificationMethod.Sas; + if (!supportsSas) { + return; + } + + session.startRequested = true; + void session.request + .startVerification(VerificationMethod.Sas) + .then((verifier) => { + this.attachVerifierToVerificationSession(session, verifier); + this.touchVerificationSession(session); + }) + .catch(() => { + session.startRequested = false; + }); + } + + private attachVerifierToVerificationSession( + session: MatrixVerificationSession, + verifier: MatrixVerifierLike, + ): void { + session.activeVerifier = verifier; + this.touchVerificationSession(session); + + const maybeSas = verifier.getShowSasCallbacks(); + if (maybeSas) { + session.sasCallbacks = maybeSas; + this.maybeAutoConfirmSas(session); + } + const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); + if (maybeReciprocateQr) { + session.reciprocateQrCallbacks = maybeReciprocateQr; + } + + const verifierObj = verifier as unknown as object; + if (this.trackedVerificationVerifiers.has(verifierObj)) { + this.ensureVerificationStarted(session); + return; + } + this.trackedVerificationVerifiers.add(verifierObj); + + verifier.on(VerifierEvent.ShowSas, (sas) => { + session.sasCallbacks = sas as MatrixShowSasCallbacks; + this.touchVerificationSession(session); + this.maybeAutoConfirmSas(session); + }); + verifier.on(VerifierEvent.ShowReciprocateQr, (qr) => { + session.reciprocateQrCallbacks = qr as MatrixShowQrCodeCallbacks; + this.touchVerificationSession(session); + }); + verifier.on(VerifierEvent.Cancel, (err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + this.ensureVerificationStarted(session); + } + + private maybeAutoConfirmSas(session: MatrixVerificationSession): void { + if (session.sasAutoConfirmStarted) { + return; + } + if (this.readRequestValue(session.request, () => session.request.initiatedByMe, true)) { + return; + } + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + return; + } + session.sasCallbacks = callbacks; + session.sasAutoConfirmStarted = true; + void callbacks + .confirm() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + private ensureVerificationStarted(session: MatrixVerificationSession): void { + if (!session.activeVerifier || session.verifyStarted) { + return; + } + session.verifyStarted = true; + const verifier = session.activeVerifier; + session.verifyPromise = verifier + .verify() + .then(() => { + this.touchVerificationSession(session); + }) + .catch((err) => { + session.error = err instanceof Error ? err.message : String(err); + this.touchVerificationSession(session); + }); + } + + trackVerificationRequest(request: MatrixVerificationRequestLike): MatrixVerificationSummary { + this.pruneVerificationSessions(Date.now()); + const txId = this.readRequestValue(request, () => request.transactionId?.trim(), ""); + if (txId) { + for (const existing of this.verificationSessions.values()) { + const existingTxId = this.readRequestValue( + existing.request, + () => existing.request.transactionId, + "", + ); + if (existingTxId === txId) { + existing.request = request; + this.ensureVerificationRequestTracked(existing); + const verifier = this.readRequestValue(request, () => request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(existing, verifier); + } + this.touchVerificationSession(existing); + return this.buildVerificationSummary(existing); + } + } + } + + const now = Date.now(); + const id = `verification-${++this.verificationSessionCounter}`; + const session: MatrixVerificationSession = { + id, + request, + createdAtMs: now, + updatedAtMs: now, + verifyStarted: false, + startRequested: false, + sasAutoConfirmStarted: false, + }; + this.verificationSessions.set(session.id, session); + this.ensureVerificationRequestTracked(session); + const verifier = this.readRequestValue(request, () => request.verifier, null); + if (verifier) { + this.attachVerifierToVerificationSession(session, verifier); + } + this.maybeAutoStartInboundSas(session); + return this.buildVerificationSummary(session); + } + + async requestOwnUserVerification( + crypto: MatrixVerificationCryptoApi | undefined, + ): Promise { + if (!crypto) { + return null; + } + const request = + (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + if (!request) { + return null; + } + return this.trackVerificationRequest(request); + } + + listVerifications(): MatrixVerificationSummary[] { + this.pruneVerificationSessions(Date.now()); + const summaries = Array.from(this.verificationSessions.values()).map((session) => + this.buildVerificationSummary(session), + ); + return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } + + async requestVerification( + crypto: MatrixVerificationCryptoApi | undefined, + params: { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + }, + ): Promise { + if (!crypto) { + throw new Error("Matrix crypto is not available"); + } + let request: MatrixVerificationRequestLike | null = null; + if (params.ownUser) { + request = (await crypto.requestOwnUserVerification()) as MatrixVerificationRequestLike | null; + } else if (params.userId && params.deviceId && crypto.requestDeviceVerification) { + request = await crypto.requestDeviceVerification(params.userId, params.deviceId); + } else if (params.userId && params.roomId && crypto.requestVerificationDM) { + request = await crypto.requestVerificationDM(params.userId, params.roomId); + } else { + throw new Error( + "Matrix verification request requires one of: ownUser, userId+deviceId, or userId+roomId", + ); + } + + if (!request) { + throw new Error("Matrix verification request could not be created"); + } + return this.trackVerificationRequest(request); + } + + async acceptVerification(id: string): Promise { + const session = this.findVerificationSession(id); + await session.request.accept(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async cancelVerification( + id: string, + params?: { reason?: string; code?: string }, + ): Promise { + const session = this.findVerificationSession(id); + await session.request.cancel(params); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + async startVerification( + id: string, + method: MatrixVerificationMethod = "sas", + ): Promise { + const session = this.findVerificationSession(id); + if (method !== "sas") { + throw new Error("Matrix startVerification currently supports only SAS directly"); + } + const verifier = await session.request.startVerification(VerificationMethod.Sas); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async generateVerificationQr(id: string): Promise<{ qrDataBase64: string }> { + const session = this.findVerificationSession(id); + const qr = await session.request.generateQRCode(); + if (!qr) { + throw new Error("Matrix verification QR data is not available yet"); + } + return { qrDataBase64: Buffer.from(qr).toString("base64") }; + } + + async scanVerificationQr(id: string, qrDataBase64: string): Promise { + const session = this.findVerificationSession(id); + const trimmed = qrDataBase64.trim(); + if (!trimmed) { + throw new Error("Matrix verification QR payload is required"); + } + const qrBytes = Buffer.from(trimmed, "base64"); + if (qrBytes.length === 0) { + throw new Error("Matrix verification QR payload is invalid base64"); + } + const verifier = await session.request.scanQRCode(new Uint8ClampedArray(qrBytes)); + this.attachVerifierToVerificationSession(session, verifier); + this.ensureVerificationStarted(session); + return this.buildVerificationSummary(session); + } + + async confirmVerificationSas(id: string): Promise { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS confirmation is not available for this verification request"); + } + session.sasCallbacks = callbacks; + await callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + mismatchVerificationSas(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS mismatch is not available for this verification request"); + } + session.sasCallbacks = callbacks; + callbacks.mismatch(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + confirmVerificationReciprocateQr(id: string): MatrixVerificationSummary { + const session = this.findVerificationSession(id); + const callbacks = + session.reciprocateQrCallbacks ?? session.activeVerifier?.getReciprocateQrCodeCallbacks(); + if (!callbacks) { + throw new Error( + "Matrix reciprocate-QR confirmation is not available for this verification request", + ); + } + session.reciprocateQrCallbacks = callbacks; + callbacks.confirm(); + this.touchVerificationSession(session); + return this.buildVerificationSummary(session); + } + + getVerificationSas(id: string): { + decimal?: [number, number, number]; + emoji?: Array<[string, string]>; + } { + const session = this.findVerificationSession(id); + const callbacks = session.sasCallbacks ?? session.activeVerifier?.getShowSasCallbacks(); + if (!callbacks) { + throw new Error("Matrix SAS data is not available for this verification request"); + } + session.sasCallbacks = callbacks; + return { + decimal: callbacks.sas.decimal, + emoji: callbacks.sas.emoji, + }; + } +} diff --git a/extensions/matrix-js/src/matrix/send.test.ts b/extensions/matrix-js/src/matrix/send.test.ts new file mode 100644 index 00000000000..d68cb35b088 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send.test.ts @@ -0,0 +1,228 @@ +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; + +const makeClient = () => { + const sendMessage = vi.fn().mockResolvedValue("evt1"); + const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); + const client = { + sendMessage, + uploadContent, + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + } as unknown as import("./sdk.js").MatrixClient; + return { client, sendMessage, uploadContent }; +}; + +describe("sendMessageMatrix media", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.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")); + }); + + 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" }, + }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/send.ts b/extensions/matrix-js/src/matrix/send.ts new file mode 100644 index 00000000000..230c26915b1 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send.ts @@ -0,0 +1,264 @@ +import type { PollInput } from "openclaw/plugin-sdk/matrix-js"; +import { getMatrixRuntime } from "../runtime.js"; +import { buildPollStartContent, M_POLL_START } from "./poll-types.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, + RelationType, + type MatrixOutboundContent, + type MatrixSendOpts, + type MatrixSendResult, + type ReactionEventContent, +} 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"; + +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, + client?: MatrixClient, +): Promise { + if (!emoji.trim()) { + throw new Error("Matrix reaction requires an emoji"); + } + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + }); + try { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + const reaction: ReactionEventContent = { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: messageId, + key: emoji, + }, + }; + await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} diff --git a/extensions/matrix-js/src/matrix/send/client.test.ts b/extensions/matrix-js/src/matrix/send/client.test.ts new file mode 100644 index 00000000000..2f839a7f1ae --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/client.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MatrixClient } from "../sdk.js"; + +const getActiveMatrixClientMock = vi.fn(); +const createMatrixClientMock = vi.fn(); +const isBunRuntimeMock = vi.fn(() => false); +const resolveMatrixAuthMock = vi.fn(); + +vi.mock("../active-client.js", () => ({ + getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args), +})); + +vi.mock("../client.js", () => ({ + createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args), + isBunRuntime: () => isBunRuntimeMock(), + resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args), +})); + +let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient; + +function createMockMatrixClient(): MatrixClient { + return { + prepareForOneOff: vi.fn(async () => undefined), + } as unknown as MatrixClient; +} + +describe("resolveMatrixClient", () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + getActiveMatrixClientMock.mockReturnValue(null); + isBunRuntimeMock.mockReturnValue(false); + resolveMatrixAuthMock.mockResolvedValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + password: undefined, + deviceId: "DEVICE123", + encryption: false, + }); + createMatrixClientMock.mockResolvedValue(createMockMatrixClient()); + + ({ resolveMatrixClient } = await import("./client.js")); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => { + vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799"); + + const result = await resolveMatrixClient({ accountId: "default" }); + + expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default"); + expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1); + expect(createMatrixClientMock).toHaveBeenCalledTimes(1); + expect(createMatrixClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + autoBootstrapCrypto: false, + }), + ); + const oneOffClient = await createMatrixClientMock.mock.results[0]?.value; + expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1); + expect(result.stopOnDone).toBe(true); + }); + + it("reuses active monitor client when available", async () => { + const activeClient = createMockMatrixClient(); + getActiveMatrixClientMock.mockReturnValue(activeClient); + + const result = await resolveMatrixClient({ accountId: "default" }); + + expect(result).toEqual({ client: activeClient, stopOnDone: false }); + expect(resolveMatrixAuthMock).not.toHaveBeenCalled(); + expect(createMatrixClientMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/send/client.ts b/extensions/matrix-js/src/matrix/send/client.ts new file mode 100644 index 00000000000..75ff3204846 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/client.ts @@ -0,0 +1,53 @@ +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 new file mode 100644 index 00000000000..bf0ed1989be --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/formatting.ts @@ -0,0 +1,93 @@ +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 new file mode 100644 index 00000000000..03d5d98d324 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/media.ts @@ -0,0 +1,234 @@ +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 new file mode 100644 index 00000000000..3e3610cd300 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/targets.test.ts @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000000..8d358ecf825 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/targets.ts @@ -0,0 +1,152 @@ +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 new file mode 100644 index 00000000000..a423294ee0e --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/types.ts @@ -0,0 +1,109 @@ +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: "m.annotation", + Replace: "m.replace", + Thread: "m.thread", +} as const; + +// Event types +export const EventType = { + Direct: "m.direct", + Reaction: "m.reaction", + 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 = { + "m.relates_to": { + rel_type: typeof RelationType.Annotation; + event_id: string; + key: string; + }; +}; + +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.test.ts b/extensions/matrix-js/src/onboarding.test.ts new file mode 100644 index 00000000000..01319f86dc1 --- /dev/null +++ b/extensions/matrix-js/src/onboarding.test.ts @@ -0,0 +1,163 @@ +import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix-js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { setMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +vi.mock("./matrix/deps.js", () => ({ + ensureMatrixSdkInstalled: vi.fn(async () => {}), + isMatrixSdkAvailable: vi.fn(() => true), +})); + +describe("matrix onboarding", () => { + const previousEnv = { + MATRIX_HOMESERVER: process.env.MATRIX_HOMESERVER, + MATRIX_USER_ID: process.env.MATRIX_USER_ID, + MATRIX_ACCESS_TOKEN: process.env.MATRIX_ACCESS_TOKEN, + MATRIX_PASSWORD: process.env.MATRIX_PASSWORD, + MATRIX_DEVICE_ID: process.env.MATRIX_DEVICE_ID, + MATRIX_DEVICE_NAME: process.env.MATRIX_DEVICE_NAME, + MATRIX_OPS_HOMESERVER: process.env.MATRIX_OPS_HOMESERVER, + MATRIX_OPS_ACCESS_TOKEN: process.env.MATRIX_OPS_ACCESS_TOKEN, + }; + + afterEach(() => { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("offers env shortcut for non-default account when scoped env vars are present", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + process.env.MATRIX_HOMESERVER = "https://matrix.env.example.org"; + process.env.MATRIX_USER_ID = "@env:example.org"; + process.env.MATRIX_PASSWORD = "env-password"; + process.env.MATRIX_ACCESS_TOKEN = ""; + process.env.MATRIX_OPS_HOMESERVER = "https://matrix.ops.env.example.org"; + process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token"; + + const confirmMessages: string[] = []; + const prompter = { + note: vi.fn(async () => {}), + select: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix-js already configured. What do you want to do?") { + return "add-account"; + } + if (message === "Matrix auth method") { + return "token"; + } + throw new Error(`unexpected select prompt: ${message}`); + }), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Matrix account name") { + return "ops"; + } + throw new Error(`unexpected text prompt: ${message}`); + }), + confirm: vi.fn(async ({ message }: { message: string }) => { + confirmMessages.push(message); + if (message.startsWith("Matrix env vars detected")) { + return true; + } + return false; + }), + } as unknown as WizardPrompter; + + const result = await matrixOnboardingAdapter.configureInteractive!({ + cfg: { + channels: { + "matrix-js": { + accounts: { + default: { + homeserver: "https://matrix.main.example.org", + accessToken: "main-token", + }, + }, + }, + }, + } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: true, + forceAllowFrom: false, + configured: true, + label: "Matrix-js", + }); + + expect(result).not.toBe("skip"); + if (result !== "skip") { + expect(result.accountId).toBe("ops"); + expect(result.cfg.channels?.["matrix-js"]?.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( + confirmMessages.some((message) => + message.startsWith( + "Matrix env vars detected (MATRIX_OPS_HOMESERVER (+ auth vars)). Use env values?", + ), + ), + ).toBe(true); + }); + + it("includes device env var names in auth help text", async () => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) => + (homeDir ?? (() => "/tmp"))(), + }, + config: { + loadConfig: () => ({}), + }, + } as never); + + const notes: string[] = []; + const prompter = { + note: vi.fn(async (message: unknown) => { + notes.push(String(message)); + }), + text: vi.fn(async () => { + throw new Error("stop-after-help"); + }), + confirm: vi.fn(async () => false), + select: vi.fn(async () => "token"), + } as unknown as WizardPrompter; + + await expect( + matrixOnboardingAdapter.configureInteractive!({ + cfg: { channels: {} } as CoreConfig, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv, + prompter, + options: undefined, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + configured: false, + label: "Matrix-js", + }), + ).rejects.toThrow("stop-after-help"); + + const noteText = notes.join("\n"); + expect(noteText).toContain("MATRIX_DEVICE_ID"); + expect(noteText).toContain("MATRIX_DEVICE_NAME"); + expect(noteText).toContain("MATRIX__DEVICE_ID"); + expect(noteText).toContain("MATRIX__DEVICE_NAME"); + }); +}); diff --git a/extensions/matrix-js/src/onboarding.ts b/extensions/matrix-js/src/onboarding.ts new file mode 100644 index 00000000000..5684d6c764e --- /dev/null +++ b/extensions/matrix-js/src/onboarding.ts @@ -0,0 +1,563 @@ +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 new file mode 100644 index 00000000000..ce2f6642f32 --- /dev/null +++ b/extensions/matrix-js/src/outbound.ts @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000000..cdf94d78b3e --- /dev/null +++ b/extensions/matrix-js/src/resolve-targets.test.ts @@ -0,0 +1,92 @@ +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 new file mode 100644 index 00000000000..a6dc93059a6 --- /dev/null +++ b/extensions/matrix-js/src/resolve-targets.ts @@ -0,0 +1,149 @@ +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 new file mode 100644 index 00000000000..d595c7a1805 --- /dev/null +++ b/extensions/matrix-js/src/runtime.ts @@ -0,0 +1,14 @@ +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 new file mode 100644 index 00000000000..88b584cee4e --- /dev/null +++ b/extensions/matrix-js/src/tool-actions.ts @@ -0,0 +1,353 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + 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, + 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 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 }); +} + +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); + + 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, { + emoji: remove ? emoji : undefined, + }); + return jsonResult({ ok: true, removed: result.removed }); + } + await reactMatrixMessage(roomId, messageId, emoji); + return jsonResult({ ok: true, added: emoji }); + } + const reactions = await listMatrixReactions(roomId, messageId); + return jsonResult({ ok: true, reactions }); + } + + 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, + }); + 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); + 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 }); + 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, + }); + 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); + return jsonResult({ ok: true, pinned: result.pinned }); + } + if (action === "unpinMessage") { + const messageId = readStringParam(params, "messageId", { required: true }); + const result = await unpinMatrixMessage(roomId, messageId); + return jsonResult({ ok: true, pinned: result.pinned }); + } + const result = await listMatrixPins(roomId); + 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, + }); + 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); + 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 new file mode 100644 index 00000000000..8b755d86d2e --- /dev/null +++ b/extensions/matrix-js/src/types.ts @@ -0,0 +1,121 @@ +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; +}; + +/** 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; + /** 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/package.json b/package.json index 22f60579ef8..4cfabb1d53b 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,10 @@ "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 03ff9dfde8f..31520f8d8d5 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -65,6 +65,7 @@ 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 fe2a9a1ea9c..f6d5485b4e3 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -69,6 +69,8 @@ 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 7053feb19a8..7a12baaf8ac 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -31,6 +31,7 @@ const entrypoints = [ "llm-task", "lobster", "matrix", + "matrix-js", "mattermost", "memory-core", "memory-lancedb", diff --git a/src/plugin-sdk/matrix-js.ts b/src/plugin-sdk/matrix-js.ts new file mode 100644 index 00000000000..f5f3557ab0a --- /dev/null +++ b/src/plugin-sdk/matrix-js.ts @@ -0,0 +1,11 @@ +// Matrix-js plugin-sdk surface. +// Reuse matrix exports to avoid drift between the two Matrix channel implementations. + +export * from "./matrix.js"; + +export type { ChannelSetupInput } from "../channels/plugins/types.js"; +export type { GatewayRequestHandlerOptions } from "../gateway/server-methods/types.js"; +export { migrateBaseNameToDefaultAccount } from "../channels/plugins/setup-helpers.js"; +export { promptAccountId } from "../channels/plugins/onboarding/helpers.js"; +export { writeJsonFileAtomically } from "./json-store.js"; +export { formatZonedTimestamp } from "../infra/format-time/format-datetime.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index ccdcd1eeb5e..fc4e0711c9d 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -26,6 +26,7 @@ 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 7e2b76d745e..ae0bb67b001 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -37,6 +37,7 @@ "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 80833de2a14..6f2c74bbbdc 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -65,6 +65,7 @@ const pluginSdkEntrypoints = [ "llm-task", "lobster", "matrix", + "matrix-js", "mattermost", "memory-core", "memory-lancedb", diff --git a/vitest.config.ts b/vitest.config.ts index 658437187f5..26bcf574955 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -33,6 +33,7 @@ const pluginSdkSubpaths = [ "llm-task", "lobster", "matrix", + "matrix-js", "mattermost", "memory-core", "memory-lancedb",