From eb10490c4cb963bac1021e02b9c484184b85e69d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 22 Feb 2026 20:38:55 -0500 Subject: [PATCH] Matrix: rename extension folder to matrix-js --- extensions/matrix-js/.DS_Store | Bin 0 -> 6148 bytes extensions/matrix-js/CHANGELOG.md | 20 + extensions/matrix-js/index.ts | 17 + extensions/matrix-js/openclaw.plugin.json | 9 + extensions/matrix-js/package.json | 37 + extensions/matrix-js/src/actions.ts | 237 ++++++ .../matrix-js/src/channel.directory.test.ts | 154 ++++ extensions/matrix-js/src/channel.ts | 489 ++++++++++++ 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/accounts.test.ts | 82 ++ extensions/matrix-js/src/matrix/accounts.ts | 137 ++++ extensions/matrix-js/src/matrix/actions.ts | 29 + .../matrix-js/src/matrix/actions/client.ts | 59 ++ .../src/matrix/actions/limits.test.ts | 15 + .../matrix-js/src/matrix/actions/limits.ts | 6 + .../matrix-js/src/matrix/actions/messages.ts | 126 +++ .../matrix-js/src/matrix/actions/pins.test.ts | 74 ++ .../matrix-js/src/matrix/actions/pins.ts | 84 ++ .../src/matrix/actions/reactions.test.ts | 109 +++ .../matrix-js/src/matrix/actions/reactions.ts | 96 +++ .../matrix-js/src/matrix/actions/room.ts | 82 ++ .../matrix-js/src/matrix/actions/summary.ts | 75 ++ .../matrix-js/src/matrix/actions/types.ts | 74 ++ .../src/matrix/actions/verification.ts | 220 +++++ .../matrix-js/src/matrix/active-client.ts | 11 + .../matrix-js/src/matrix/client-bootstrap.ts | 39 + .../matrix-js/src/matrix/client.test.ts | 399 ++++++++++ extensions/matrix-js/src/matrix/client.ts | 14 + .../matrix-js/src/matrix/client/config.ts | 327 ++++++++ .../src/matrix/client/create-client.ts | 56 ++ .../matrix-js/src/matrix/client/logging.ts | 36 + .../src/matrix/client/register-mode.test.ts | 97 +++ .../src/matrix/client/register-mode.ts | 125 +++ .../matrix-js/src/matrix/client/runtime.ts | 4 + .../matrix-js/src/matrix/client/shared.ts | 173 ++++ .../matrix-js/src/matrix/client/storage.ts | 134 ++++ .../matrix-js/src/matrix/client/types.ts | 40 + .../matrix-js/src/matrix/credentials.ts | 125 +++ 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 +++ .../matrix-js/src/matrix/monitor/events.ts | 101 +++ .../matrix-js/src/matrix/monitor/handler.ts | 665 ++++++++++++++++ .../matrix-js/src/matrix/monitor/index.ts | 365 +++++++++ .../matrix-js/src/matrix/monitor/location.ts | 100 +++ .../src/matrix/monitor/media.test.ts | 102 +++ .../matrix-js/src/matrix/monitor/media.ts | 118 +++ .../src/matrix/monitor/mentions.test.ts | 154 ++++ .../matrix-js/src/matrix/monitor/mentions.ts | 62 ++ .../src/matrix/monitor/replies.test.ts | 132 +++ .../matrix-js/src/matrix/monitor/replies.ts | 100 +++ .../matrix-js/src/matrix/monitor/room-info.ts | 55 ++ .../src/matrix/monitor/rooms.test.ts | 39 + .../matrix-js/src/matrix/monitor/rooms.ts | 47 ++ .../matrix-js/src/matrix/monitor/threads.ts | 68 ++ .../matrix-js/src/matrix/monitor/types.ts | 27 + .../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 ++ extensions/matrix-js/src/matrix/sdk.test.ts | 751 ++++++++++++++++++ extensions/matrix-js/src/matrix/sdk.ts | 527 ++++++++++++ .../src/matrix/sdk/crypto-bootstrap.test.ts | 241 ++++++ .../src/matrix/sdk/crypto-bootstrap.ts | 226 ++++++ .../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 | 57 ++ .../src/matrix/sdk/recovery-key-store.test.ts | 176 ++++ .../src/matrix/sdk/recovery-key-store.ts | 253 ++++++ .../matrix-js/src/matrix/sdk/transport.ts | 171 ++++ extensions/matrix-js/src/matrix/sdk/types.ts | 183 +++++ .../matrix/sdk/verification-manager.test.ts | 170 ++++ .../src/matrix/sdk/verification-manager.ts | 464 +++++++++++ extensions/matrix-js/src/matrix/send.test.ts | 155 ++++ extensions/matrix-js/src/matrix/send.ts | 260 ++++++ .../matrix-js/src/matrix/send/client.ts | 67 ++ .../matrix-js/src/matrix/send/formatting.ts | 93 +++ extensions/matrix-js/src/matrix/send/media.ts | 229 ++++++ .../matrix-js/src/matrix/send/targets.test.ts | 98 +++ .../matrix-js/src/matrix/send/targets.ts | 150 ++++ extensions/matrix-js/src/matrix/send/types.ts | 109 +++ extensions/matrix-js/src/onboarding.ts | 452 +++++++++++ extensions/matrix-js/src/outbound.ts | 55 ++ .../matrix-js/src/resolve-targets.test.ts | 67 ++ extensions/matrix-js/src/resolve-targets.ts | 126 +++ extensions/matrix-js/src/runtime.ts | 14 + extensions/matrix-js/src/tool-actions.ts | 294 +++++++ extensions/matrix-js/src/types.ts | 121 +++ 103 files changed, 13918 insertions(+) create mode 100644 extensions/matrix-js/.DS_Store create mode 100644 extensions/matrix-js/CHANGELOG.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/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/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/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.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/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/register-mode.test.ts create mode 100644 extensions/matrix-js/src/matrix/client/register-mode.ts create mode 100644 extensions/matrix-js/src/matrix/client/runtime.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/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.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/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/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.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.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 diff --git a/extensions/matrix-js/.DS_Store b/extensions/matrix-js/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..d489243d000a68f9cee5413a29c18b10d16ab8fa GIT binary patch literal 6148 zcmeHKJ5Iwu5S>jT6bp)!lzWBTz(nQ*IRGdhpors$D7`O0LrVn+QP9&-fke*}XMq8<@-p^U8v!wT2+#im!bRh?iZkuA$EJ;SQY=oHZ$JtB%dHuZC z96u@IFV7#2?yj2S7boSjcq~nATGAkyMMn4duU#otxr(*R`!ct zc|Q|lz!)$FHiZGyY?kh}ppC|WF<=Z74Dk2CLm6YmTrhk(FoYHW*nv3+=G;qgj#rEo zb3u3@PEvuA>a@jhk`BAqxL7e4lyq|1d^p|NX@}y%?pWW4aB{JrjmCg6P-UPkk3Fvc z`@i@9)g*f|28@A?V!(BhVKTrgX>F~&9M@V8J%qAwTrOCoU=m6(V!0IWLW98Wc>;_T Tb3s@j_9GBzu)!GkQwF{PN48hN literal 0 HcmV?d00001 diff --git a/extensions/matrix-js/CHANGELOG.md b/extensions/matrix-js/CHANGELOG.md new file mode 100644 index 00000000000..fcbaf44e2d9 --- /dev/null +++ b/extensions/matrix-js/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 2026.2.22 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.14 + +### Features + +- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name). +- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support. +- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts. +- Threads: replyToMode controls and thread replies (off/inbound/always). +- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes. +- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info. +- Auto-join invites with allowlist support. +- Status + probe reporting for health checks. diff --git a/extensions/matrix-js/index.ts b/extensions/matrix-js/index.ts new file mode 100644 index 00000000000..10df32f7f79 --- /dev/null +++ b/extensions/matrix-js/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { matrixPlugin } from "./src/channel.js"; +import { setMatrixRuntime } from "./src/runtime.js"; + +const plugin = { + id: "matrix", + name: "Matrix", + description: "Matrix channel plugin (matrix-js-sdk)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setMatrixRuntime(api.runtime); + api.registerChannel({ plugin: matrixPlugin }); + }, +}; + +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..2c179d9c6a9 --- /dev/null +++ b/extensions/matrix-js/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "matrix", + "channels": ["matrix"], + "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..89d5ce2b038 --- /dev/null +++ b/extensions/matrix-js/package.json @@ -0,0 +1,37 @@ +{ + "name": "@openclaw/matrix", + "version": "2026.2.6-3", + "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", + "label": "Matrix", + "selectionLabel": "Matrix (plugin)", + "docsPath": "/channels/matrix", + "docsLabel": "matrix", + "blurb": "open protocol; install the plugin to enable.", + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/matrix", + "localPath": "extensions/matrix", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/matrix-js/src/actions.ts b/extensions/matrix-js/src/actions.ts new file mode 100644 index 00000000000..9b4f3ac89ca --- /dev/null +++ b/extensions/matrix-js/src/actions.ts @@ -0,0 +1,237 @@ +import { + createActionGate, + readNumberParam, + readStringParam, + type ChannelMessageActionAdapter, + type ChannelMessageActionContext, + type ChannelMessageActionName, + type ChannelToolSend, +} from "openclaw/plugin-sdk"; +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?.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-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.`); + }, +}; 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..5fc6bbe28fb --- /dev/null +++ b/extensions/matrix-js/src/channel.directory.test.ts @@ -0,0 +1,154 @@ +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { matrixPlugin } from "./channel.js"; +import { setMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +vi.mock("@vector-im/matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + MatrixClient: class {}, + LogService: { + setLogger: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, + SimpleFsStorageProvider: class {}, + RustSdkCryptoStorageProvider: class {}, +})); + +describe("matrix directory", () => { + const runtimeEnv: RuntimeEnv = { + log: vi.fn(), + 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: { + 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: { + 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: { + 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); + }); +}); diff --git a/extensions/matrix-js/src/channel.ts b/extensions/matrix-js/src/channel.ts new file mode 100644 index 00000000000..30c691b8d9d --- /dev/null +++ b/extensions/matrix-js/src/channel.ts @@ -0,0 +1,489 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; +import { matrixMessageActions } from "./actions.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 { resolveMatrixAuth } from "./matrix/client.js"; +import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; +import { probeMatrix } from "./matrix/probe.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", + label: "Matrix", + selectionLabel: "Matrix (plugin)", + docsPath: "/channels/matrix", + docsLabel: "matrix", + 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 buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + register?: boolean; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(typeof input.register === "boolean" ? { register: input.register } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + +export const matrixPlugin: ChannelPlugin = { + id: "matrix", + 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"] }, + 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", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + clearBaseFields: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "register", + "deviceName", + "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.accounts.${accountId}.dm` + : "channels.matrix.dm"; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + policyPath: `${prefix}.policy`, + allowFromPath: `${prefix}.allowFrom`, + approveHint: formatPairingApproveHint("matrix"), + normalizeEntry: (raw) => normalizeMatrixUserId(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg as CoreConfig); + const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy, + }); + if (groupPolicy !== "open") { + return []; + } + return [ + '- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.', + ]; + }, + }, + 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 }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + 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, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId: DEFAULT_ACCOUNT_ID, + name: input.name, + }); + if (input.useEnv) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + matrix: { + ...namedConfig.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(namedConfig as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: input.password?.trim(), + deviceName: input.deviceName?.trim(), + 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", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs, cfg }) => { + try { + const auth = await resolveMatrixAuth({ + cfg: cfg as CoreConfig, + accountId: account.accountId, + }); + return await probeMatrix({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + userId: auth.userId, + timeoutMs, + }); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs: 0, + }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.homeserver, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastProbeAt: runtime?.lastProbeAt ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.homeserver, + }); + ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`); + + // Serialize startup: wait for any previous startup to complete import phase. + // This works around a race condition with concurrent dynamic imports. + // + // INVARIANT: The import() below cannot hang because: + // 1. It only loads local ESM modules with no circular awaits + // 2. Module initialization is synchronous (no top-level await in ./matrix/index.js) + // 3. The lock only serializes the import phase, not the provider startup + const previousLock = matrixStartupLock; + let releaseLock: () => void = () => {}; + matrixStartupLock = new Promise((resolve) => { + releaseLock = resolve; + }); + await previousLock; + + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + // Wrap in try/finally to ensure lock is released even if import fails. + let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider; + try { + const module = await import("./matrix/index.js"); + monitorMatrixProvider = module.monitorMatrixProvider; + } finally { + // Release lock after import completes or fails + releaseLock(); + } + + return monitorMatrixProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + initialSyncLimit: account.config.initialSyncLimit, + replyToMode: account.config.replyToMode, + accountId: account.accountId, + }); + }, + }, +}; diff --git a/extensions/matrix-js/src/config-schema.ts b/extensions/matrix-js/src/config-schema.ts new file mode 100644 index 00000000000..ea684060a70 --- /dev/null +++ b/extensions/matrix-js/src/config-schema.ts @@ -0,0 +1,66 @@ +import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk"; +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(), + register: z.boolean().optional(), + deviceId: z.string().optional(), + deviceName: 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..d499574bc8d --- /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: {} } }; + + 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..e7c8fd45920 --- /dev/null +++ b/extensions/matrix-js/src/directory-live.ts @@ -0,0 +1,208 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +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..b324b4197a7 --- /dev/null +++ b/extensions/matrix-js/src/group-mentions.ts @@ -0,0 +1,52 @@ +import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; +import { resolveMatrixAccountConfig } from "./matrix/accounts.js"; +import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; +import type { CoreConfig } from "./types.js"; + +function stripLeadingPrefixCaseInsensitive(value: string, prefix: string): string { + return value.toLowerCase().startsWith(prefix.toLowerCase()) + ? value.slice(prefix.length).trim() + : value; +} + +function resolveMatrixRoomConfigForGroup(params: ChannelGroupContext) { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + roomId = stripLeadingPrefixCaseInsensitive(roomId, "matrix:"); + roomId = stripLeadingPrefixCaseInsensitive(roomId, "channel:"); + roomId = stripLeadingPrefixCaseInsensitive(roomId, "room:"); + + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId }); + return resolveMatrixRoomConfig({ + rooms: matrixConfig.groups ?? matrixConfig.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; +} + +export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { + const resolved = resolveMatrixRoomConfigForGroup(params); + if (resolved) { + if (resolved.autoReply === true) { + return false; + } + if (resolved.autoReply === false) { + return true; + } + if (typeof resolved.requireMention === "boolean") { + return resolved.requireMention; + } + } + return true; +} + +export function resolveMatrixGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const resolved = resolveMatrixRoomConfigForGroup(params); + return resolved?.tools; +} diff --git a/extensions/matrix-js/src/matrix/accounts.test.ts b/extensions/matrix-js/src/matrix/accounts.test.ts new file mode 100644 index 00000000000..d453684756c --- /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: { + 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: { + 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: { + 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..ca0716ce505 --- /dev/null +++ b/extensions/matrix-js/src/matrix/accounts.ts @@ -0,0 +1,137 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { CoreConfig, MatrixConfig } from "../types.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 = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + 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 { + const accounts = cfg.channels?.matrix?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + // Direct lookup first (fast path for already-normalized keys) + if (accounts[accountId]) { + return accounts[accountId] as MatrixConfig; + } + // Fall back to case-insensitive match (user may have mixed-case keys in config) + const normalized = normalizeAccountId(accountId); + for (const key of Object.keys(accounts)) { + if (normalizeAccountId(key) === normalized) { + return accounts[key] as MatrixConfig; + } + } + return undefined; +} + +export function resolveMatrixAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedMatrixAccount { + const accountId = normalizeAccountId(params.accountId); + const matrixBase = params.cfg.channels?.matrix ?? {}; + 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 = params.cfg.channels?.matrix ?? {}; + 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..5614ff92f9d --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions.ts @@ -0,0 +1,29 @@ +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 { + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, + getMatrixVerificationSas, + listMatrixVerifications, + mismatchMatrixVerificationSas, + requestMatrixVerification, + scanMatrixVerificationQr, + startMatrixVerification, +} from "./actions/verification.js"; +export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix-js/src/matrix/actions/client.ts b/extensions/matrix-js/src/matrix/actions/client.ts new file mode 100644 index 00000000000..131eec67485 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/client.ts @@ -0,0 +1,59 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { CoreConfig } from "../types.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(); + if (active) { + return { client: active, stopOnDone: false }; + } + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + }); + 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, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off actions. + } + } + await client.start(); + return { client, stopOnDone: true }; +} 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..3e4ddd39d3c --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/messages.ts @@ -0,0 +1,126 @@ +import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; +import { resolveActionClient } 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"); + } + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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 }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function deleteMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { reason?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + await client.redactEvent(resolvedRoom, messageId, opts.reason); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function readMatrixMessages( + roomId: string, + opts: MatrixActionClientOpts & { + limit?: number; + before?: string; + after?: string; + } = {}, +): Promise<{ + messages: MatrixMessageSummary[]; + nextBatch?: string | null; + prevBatch?: string | null; +}> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} 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..2b432c1a85c --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/pins.test.ts @@ -0,0 +1,74 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { describe, expect, it, vi } from "vitest"; +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..52baf69fd12 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/pins.ts @@ -0,0 +1,84 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { fetchEventSummary, readPinnedEvents } from "./summary.js"; +import { + EventType, + type MatrixActionClientOpts, + type MatrixActionClient, + type MatrixMessageSummary, + type RoomPinnedEventsEventContent, +} from "./types.js"; + +type ActionClient = MatrixActionClient["client"]; + +async function withResolvedPinRoom( + roomId: string, + opts: MatrixActionClientOpts, + run: (client: ActionClient, resolvedRoom: string) => Promise, +): Promise { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + return await run(client, resolvedRoom); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +async function updateMatrixPins( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts, + update: (current: string[]) => string[], +): Promise<{ pinned: string[] }> { + return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { + const current = await readPinnedEvents(client, resolvedRoom); + const next = update(current); + const payload: RoomPinnedEventsEventContent = { pinned: next }; + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); + return { pinned: next }; + }); +} + +export async function pinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + return await updateMatrixPins(roomId, messageId, opts, (current) => + current.includes(messageId) ? current : [...current, messageId], + ); +} + +export async function unpinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + return await updateMatrixPins(roomId, messageId, opts, (current) => + current.filter((id) => id !== messageId), + ); +} + +export async function listMatrixPins( + roomId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { + return await withResolvedPinRoom(roomId, opts, async (client, resolvedRoom) => { + const pinned = await readPinnedEvents(client, resolvedRoom); + const events = ( + await Promise.all( + pinned.map(async (eventId) => { + try { + return await fetchEventSummary(client, resolvedRoom, eventId); + } catch { + return null; + } + }), + ) + ).filter((event): event is MatrixMessageSummary => Boolean(event)); + return { pinned, events }; + }); +} diff --git a/extensions/matrix-js/src/matrix/actions/reactions.test.ts b/extensions/matrix-js/src/matrix/actions/reactions.test.ts new file mode 100644 index 00000000000..aab161b54c0 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/reactions.test.ts @@ -0,0 +1,109 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { describe, expect, it, vi } from "vitest"; +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..5be6642169f --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/reactions.ts @@ -0,0 +1,96 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } 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 { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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()); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function removeMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { emoji?: string } = {}, +): Promise<{ removed: number }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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 }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} 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..75e67b97383 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/room.ts @@ -0,0 +1,82 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { EventType, type MatrixActionClientOpts } from "./types.js"; + +export async function getMatrixMemberInfo( + userId: string, + opts: MatrixActionClientOpts & { roomId?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + 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, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} 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..8d49c060784 --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/types.ts @@ -0,0 +1,74 @@ +import type { MatrixClient, MatrixRawEvent, MessageEventContent } 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..d4f0564e03c --- /dev/null +++ b/extensions/matrix-js/src/matrix/actions/verification.ts @@ -0,0 +1,220 @@ +import { resolveActionClient } 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.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 = {}) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.listVerifications(); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function requestMatrixVerification( + params: MatrixActionClientOpts & { + ownUser?: boolean; + userId?: string; + deviceId?: string; + roomId?: string; + } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(params); + try { + 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, + }); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function acceptMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.acceptVerification(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function cancelMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { reason?: string; code?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.cancelVerification(resolveVerificationId(requestId), { + reason: opts.reason?.trim() || undefined, + code: opts.code?.trim() || undefined, + }); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function startMatrixVerification( + requestId: string, + opts: MatrixActionClientOpts & { method?: "sas" } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.startVerification(resolveVerificationId(requestId), opts.method ?? "sas"); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function generateMatrixVerificationQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.generateVerificationQr(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function scanMatrixVerificationQr( + requestId: string, + qrDataBase64: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.getVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function confirmMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function mismatchMatrixVerificationSas( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.mismatchVerificationSas(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function confirmMatrixVerificationReciprocateQr( + requestId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const crypto = requireCrypto(client); + return await crypto.confirmVerificationReciprocateQr(resolveVerificationId(requestId)); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixEncryptionStatus( + opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + 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, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} 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..dbb04ea347b --- /dev/null +++ b/extensions/matrix-js/src/matrix/active-client.ts @@ -0,0 +1,11 @@ +import type { MatrixClient } from "./sdk.js"; + +let activeClient: MatrixClient | null = null; + +export function setActiveMatrixClient(client: MatrixClient | null): void { + activeClient = client; +} + +export function getActiveMatrixClient(): MatrixClient | null { + return activeClient; +} 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..82fc5ce6ac8 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client.test.ts @@ -0,0 +1,399 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../types.js"; +import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js"; +import * as credentialsModule from "./credentials.js"; +import * as sdkModule from "./sdk.js"; + +const saveMatrixCredentialsMock = vi.fn(); +const prepareMatrixRegisterModeMock = vi.fn(async () => null); +const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false); + +vi.mock("./credentials.js", () => ({ + loadMatrixCredentials: vi.fn(() => null), + saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args), + credentialsMatchConfig: vi.fn(() => false), + touchMatrixCredentials: vi.fn(), +})); + +vi.mock("./client/register-mode.js", () => ({ + prepareMatrixRegisterMode: (...args: unknown[]) => prepareMatrixRegisterModeMock(...args), + finalizeMatrixRegisterConfigAfterSuccess: (...args: unknown[]) => + finalizeMatrixRegisterConfigAfterSuccessMock(...args), + resetPreparedMatrixRegisterModesForTests: vi.fn(), +})); + +describe("resolveMatrixConfig", () => { + it("prefers config over env", () => { + const cfg = { + channels: { + matrix: { + 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", + register: false, + 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.register).toBe(false); + expect(resolved.deviceId).toBe("ENVDEVICE"); + expect(resolved.deviceName).toBe("EnvDevice"); + expect(resolved.initialSyncLimit).toBeUndefined(); + expect(resolved.encryption).toBe(false); + }); + + it("reads register flag from config and env", () => { + const cfg = { + channels: { + matrix: { + register: true, + }, + }, + } as CoreConfig; + const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv); + expect(resolvedFromCfg.register).toBe(true); + + const resolvedFromEnv = resolveMatrixConfig( + {} as CoreConfig, + { + MATRIX_REGISTER: "1", + } as NodeJS.ProcessEnv, + ); + expect(resolvedFromEnv.register).toBe(true); + }); +}); + +describe("resolveMatrixAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + saveMatrixCredentialsMock.mockReset(); + prepareMatrixRegisterModeMock.mockReset(); + finalizeMatrixRegisterConfigAfterSuccessMock.mockReset(); + }); + + it("uses the hardened client request path for password login and persists deviceId", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + access_token: "tok-123", + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenCalledWith( + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + }), + ); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + }), + ); + }); + + it("can register account when password login fails and register mode is enabled", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest"); + doRequestSpy + .mockRejectedValueOnce(new Error("Invalid username or password")) + .mockResolvedValueOnce({ + access_token: "tok-registered", + user_id: "@newbot:example.org", + device_id: "REGDEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + password: "secret", + register: true, + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenNthCalledWith( + 1, + "POST", + "/_matrix/client/v3/login", + undefined, + expect.objectContaining({ + type: "m.login.password", + device_id: undefined, + }), + ); + expect(doRequestSpy).toHaveBeenNthCalledWith( + 2, + "POST", + "/_matrix/client/v3/register", + undefined, + expect.objectContaining({ + username: "newbot", + auth: { type: "m.login.dummy" }, + }), + ); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + accessToken: "tok-registered", + deviceId: "REGDEVICE123", + encryption: true, + }); + expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({ + cfg, + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + env: {} as NodeJS.ProcessEnv, + }); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({ + homeserver: "https://matrix.example.org", + userId: "@newbot:example.org", + deviceId: "REGDEVICE123", + }); + }); + + it("ignores cached credentials when matrix.register=true", 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 doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + access_token: "tok-123", + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + register: 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.accessToken).toBe("tok-123"); + expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1); + }); + + it("requires matrix.password when matrix.register=true", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + register: true, + }, + }, + } as CoreConfig; + + await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + "Matrix password is required when matrix.register=true", + ); + expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); + }); + + it("requires matrix.userId when matrix.register=true", async () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + password: "secret", + register: true, + }, + }, + } as CoreConfig; + + await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow( + "Matrix userId is required when matrix.register=true", + ); + expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled(); + expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled(); + }); + + it("falls back to config deviceId when cached credentials are missing it", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth.deviceId).toBe("DEVICE123"); + expect(saveMatrixCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + }), + ); + }); + + it("resolves missing whoami identity fields for token auth", async () => { + const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ + user_id: "@bot:example.org", + device_id: "DEVICE123", + }); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + }); + + expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami"); + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); + + it("uses config deviceId with cached credentials when token is loaded from cache", async () => { + vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + createdAt: "2026-01-01T00:00:00.000Z", + }); + vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + + const cfg = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + deviceId: "DEVICE123", + encryption: true, + }, + }, + } as CoreConfig; + + const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(auth).toMatchObject({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + encryption: true, + }); + }); +}); diff --git a/extensions/matrix-js/src/matrix/client.ts b/extensions/matrix-js/src/matrix/client.ts new file mode 100644 index 00000000000..53abe1c3d5f --- /dev/null +++ b/extensions/matrix-js/src/matrix/client.ts @@ -0,0 +1,14 @@ +export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; +export { isBunRuntime } from "./client/runtime.js"; +export { + resolveMatrixConfig, + resolveMatrixConfigForAccount, + 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..90bd5ff85fa --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/config.ts @@ -0,0 +1,327 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import { MatrixClient } from "../sdk.js"; +import type { CoreConfig } from "../types.js"; +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import { + finalizeMatrixRegisterConfigAfterSuccess, + prepareMatrixRegisterMode, +} from "./register-mode.js"; +import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; + +function clean(value?: string): string { + return value?.trim() ?? ""; +} + +function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function resolveMatrixLocalpart(userId: string): string { + const trimmed = userId.trim(); + const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; + const localpart = noPrefix.split(":")[0]?.trim() || ""; + if (!localpart) { + throw new Error(`Invalid Matrix userId for registration: ${userId}`); + } + return localpart; +} + +async function registerMatrixPasswordAccount(params: { + homeserver: string; + userId: string; + password: string; + deviceId?: string; + deviceName?: string; +}): Promise<{ + access_token?: string; + user_id?: string; + device_id?: string; +}> { + const registerClient = new MatrixClient(params.homeserver, ""); + const payload = { + username: resolveMatrixLocalpart(params.userId), + password: params.password, + inhibit_login: false, + device_id: params.deviceId, + initial_device_display_name: params.deviceName ?? "OpenClaw Gateway", + }; + + let firstError: unknown = null; + try { + return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, { + ...payload, + auth: { type: "m.login.dummy" }, + })) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + } catch (err) { + firstError = err; + } + + try { + return (await registerClient.doRequest( + "POST", + "/_matrix/client/v3/register", + undefined, + payload, + )) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + } catch (err) { + const firstMessage = firstError instanceof Error ? firstError.message : String(firstError); + const secondMessage = err instanceof Error ? err.message : String(err); + throw new Error( + `Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`, + ); + } +} + +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): MatrixResolvedConfig { + const matrix = cfg.channels?.matrix ?? {}; + const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); + const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); + const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; + const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; + const register = + parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false; + const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined; + const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || 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, + register, + deviceId, + deviceName, + initialSyncLimit, + encryption, + }; +} + +export async function resolveMatrixAuth(params?: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; +}): Promise { + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const env = params?.env ?? process.env; + const resolved = resolveMatrixConfig(cfg, env); + const registerFromConfig = cfg.channels?.matrix?.register === true; + if (!resolved.homeserver) { + throw new Error("Matrix homeserver is required (matrix.homeserver)"); + } + + const { + loadMatrixCredentials, + saveMatrixCredentials, + credentialsMatchConfig, + touchMatrixCredentials, + } = await import("../credentials.js"); + + const cached = loadMatrixCredentials(env); + const cachedCredentials = + cached && + credentialsMatchConfig(cached, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + ? cached + : null; + + if (registerFromConfig) { + if (!resolved.userId) { + throw new Error("Matrix userId is required when matrix.register=true"); + } + if (!resolved.password) { + throw new Error("Matrix password is required when matrix.register=true"); + } + await prepareMatrixRegisterMode({ + cfg, + homeserver: resolved.homeserver, + userId: resolved.userId, + env, + }); + } + + // If we have an access token, we can fetch userId via whoami if not provided + if (resolved.accessToken && !registerFromConfig) { + 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) { + saveMatrixCredentials({ + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + deviceId: knownDeviceId, + }); + } else if (hasMatchingCachedToken) { + touchMatrixCredentials(env); + } + return { + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + password: resolved.password, + deviceId: knownDeviceId, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + if (cachedCredentials && !registerFromConfig) { + touchMatrixCredentials(env); + 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.userId)"); + } + + if (!resolved.password) { + throw new Error( + "Matrix password is required when no access token is configured (matrix.password)", + ); + } + + // Login with password using the same hardened request path as other Matrix HTTP calls. + ensureMatrixSdkLoggingConfigured(); + const loginClient = new MatrixClient(resolved.homeserver, ""); + let login: { + access_token?: string; + user_id?: string; + device_id?: string; + }; + try { + 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; + }; + } catch (loginErr) { + if (!resolved.register) { + throw loginErr; + } + try { + login = await registerMatrixPasswordAccount({ + homeserver: resolved.homeserver, + userId: resolved.userId, + password: resolved.password, + deviceId: resolved.deviceId, + deviceName: resolved.deviceName, + }); + } catch (registerErr) { + const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr); + const registerMessage = + registerErr instanceof Error ? registerErr.message : String(registerErr); + throw new Error( + `Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`, + ); + } + } + + const accessToken = login.access_token?.trim(); + if (!accessToken) { + throw new Error("Matrix login/registration 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, + }; + + saveMatrixCredentials({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + deviceId: auth.deviceId, + }); + + if (registerFromConfig) { + await finalizeMatrixRegisterConfigAfterSuccess({ + homeserver: auth.homeserver, + userId: auth.userId, + deviceId: auth.deviceId, + }); + } + + 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..7626a6c8477 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/create-client.ts @@ -0,0 +1,56 @@ +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; +}): 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-${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, + }); +} 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..b4914678424 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/logging.ts @@ -0,0 +1,36 @@ +import { ConsoleLogger, LogService } from "../sdk/logger.js"; + +let matrixSdkLoggingConfigured = false; +const matrixSdkBaseLogger = new ConsoleLogger(); + +function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { + if (module !== "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) { + return; + } + matrixSdkLoggingConfigured = true; + + LogService.setLogger({ + trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), + debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), + info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), + warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), + error: (module, ...messageOrObject) => { + if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { + return; + } + matrixSdkBaseLogger.error(module, ...messageOrObject); + }, + }); +} diff --git a/extensions/matrix-js/src/matrix/client/register-mode.test.ts b/extensions/matrix-js/src/matrix/client/register-mode.test.ts new file mode 100644 index 00000000000..7fb20f76387 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/register-mode.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as runtimeModule from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; +import { + finalizeMatrixRegisterConfigAfterSuccess, + prepareMatrixRegisterMode, + resetPreparedMatrixRegisterModesForTests, +} from "./register-mode.js"; + +describe("matrix register mode helpers", () => { + const tempDirs: string[] = []; + + afterEach(() => { + resetPreparedMatrixRegisterModesForTests(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it("moves existing matrix state into a .bak snapshot before fresh registration", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-")); + tempDirs.push(stateDir); + const credentialsDir = path.join(stateDir, "credentials", "matrix"); + const accountsDir = path.join(credentialsDir, "accounts"); + fs.mkdirSync(accountsDir, { recursive: true }); + fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n'); + fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n"); + + const cfg = { + channels: { + matrix: { + userId: "@pinguini:matrix.gumadeiras.com", + register: true, + encryption: true, + }, + }, + } as CoreConfig; + + const backupDir = await prepareMatrixRegisterMode({ + cfg, + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv, + }); + + expect(backupDir).toBeTruthy(); + expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false); + expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false); + expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true); + expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true); + expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true); + }); + + it("updates matrix config after successful register mode auth", async () => { + const writeConfigFile = vi.fn(async () => {}); + vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({ + config: { + loadConfig: () => + ({ + channels: { + matrix: { + register: true, + accessToken: "stale-token", + userId: "@pinguini:matrix.gumadeiras.com", + }, + }, + }) as CoreConfig, + writeConfigFile, + }, + } as never); + + const updated = await finalizeMatrixRegisterConfigAfterSuccess({ + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + deviceId: "DEVICE123", + }); + expect(updated).toBe(true); + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: expect.objectContaining({ + matrix: expect.objectContaining({ + register: false, + homeserver: "https://matrix.gumadeiras.com", + userId: "@pinguini:matrix.gumadeiras.com", + deviceId: "DEVICE123", + }), + }), + }), + ); + const written = writeConfigFile.mock.calls[0]?.[0] as CoreConfig; + expect(written.channels?.matrix?.accessToken).toBeUndefined(); + }); +}); diff --git a/extensions/matrix-js/src/matrix/client/register-mode.ts b/extensions/matrix-js/src/matrix/client/register-mode.ts new file mode 100644 index 00000000000..4ffed2c9bf3 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/register-mode.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../../types.js"; +import { resolveMatrixCredentialsDir } from "../credentials.js"; + +const preparedRegisterKeys = new Set(); + +function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string { + try { + return getMatrixRuntime().state.resolveStateDir(env, os.homedir); + } catch { + // fall through to deterministic fallback for tests/early init + } + const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); + if (override) { + if (override.startsWith("~")) { + const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir()); + return path.resolve(expanded); + } + return path.resolve(override); + } + return path.join(os.homedir(), ".openclaw"); +} + +function buildRegisterKey(params: { homeserver: string; userId: string }): string { + return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`; +} + +function buildBackupDirName(now = new Date()): string { + const ts = now.toISOString().replace(/[:.]/g, "-"); + const suffix = Math.random().toString(16).slice(2, 8); + return `${ts}-${suffix}`; +} + +export async function prepareMatrixRegisterMode(params: { + cfg: CoreConfig; + homeserver: string; + userId: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; + const registerKey = buildRegisterKey({ + homeserver: params.homeserver, + userId: params.userId, + }); + if (preparedRegisterKeys.has(registerKey)) { + return null; + } + + const stateDir = resolveStateDirFromEnv(env); + const credentialsDir = resolveMatrixCredentialsDir(env, stateDir); + if (!fs.existsSync(credentialsDir)) { + return null; + } + + const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak"); + if (entries.length === 0) { + return null; + } + + const backupRoot = path.join(credentialsDir, ".bak"); + fs.mkdirSync(backupRoot, { recursive: true }); + const backupDir = path.join(backupRoot, buildBackupDirName()); + fs.mkdirSync(backupDir, { recursive: true }); + + const matrixConfig = params.cfg.channels?.matrix ?? {}; + fs.writeFileSync( + path.join(backupDir, "matrix-config.json"), + JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"), + "utf-8", + ); + + for (const entry of entries) { + fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry)); + } + + preparedRegisterKeys.add(registerKey); + return backupDir; +} + +export async function finalizeMatrixRegisterConfigAfterSuccess(params: { + homeserver: string; + userId: string; + deviceId?: string; +}): Promise { + let runtime: ReturnType | null = null; + try { + runtime = getMatrixRuntime(); + } catch { + return false; + } + + const cfg = runtime.config.loadConfig() as CoreConfig; + if (cfg.channels?.matrix?.register !== true) { + return false; + } + + const matrixCfg = cfg.channels?.matrix ?? {}; + const nextMatrix: Record = { + ...matrixCfg, + register: false, + homeserver: params.homeserver, + userId: params.userId, + ...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}), + }; + // Registration mode should continue relying on password + cached credentials, not stale inline token. + delete nextMatrix.accessToken; + + const next: CoreConfig = { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + matrix: nextMatrix as CoreConfig["channels"]["matrix"], + }, + }; + + await runtime.config.writeConfigFile(next as never); + return true; +} + +export function resetPreparedMatrixRegisterModesForTests(): void { + preparedRegisterKeys.clear(); +} 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.ts b/extensions/matrix-js/src/matrix/client/shared.ts new file mode 100644 index 00000000000..4f04ad71321 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/shared.ts @@ -0,0 +1,173 @@ +import type { MatrixClient } from "../sdk.js"; +import { LogService } from "../sdk/logger.js"; +import type { CoreConfig } from "../types.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; +}; + +let sharedClientState: SharedMatrixClientState | null = null; +let sharedClientPromise: Promise | null = null; +let sharedClientStartPromise: Promise | null = null; + +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, + }; +} + +async function ensureSharedClientStarted(params: { + state: SharedMatrixClientState; + timeoutMs?: number; + initialSyncLimit?: number; + encryption?: boolean; +}): Promise { + if (params.state.started) { + return; + } + if (sharedClientStartPromise) { + await sharedClientStartPromise; + return; + } + sharedClientStartPromise = (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 sharedClientStartPromise; + } finally { + sharedClientStartPromise = 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 })); + const key = buildSharedClientKey(auth, params.accountId); + const shouldStart = params.startClient !== false; + + if (sharedClientState?.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: sharedClientState, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return sharedClientState.client; + } + + if (sharedClientPromise) { + const pending = await sharedClientPromise; + if (pending.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return pending.client; + } + pending.client.stop(); + sharedClientState = null; + sharedClientPromise = null; + } + + sharedClientPromise = createSharedMatrixClient({ + auth, + timeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + try { + const created = await sharedClientPromise; + sharedClientState = created; + if (shouldStart) { + await ensureSharedClientStarted({ + state: created, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return created.client; + } finally { + sharedClientPromise = null; + } +} + +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 { + if (sharedClientState) { + sharedClientState.client.stop(); + sharedClientState = null; + } +} 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..2575eb2c8f2 --- /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", "bot-storage.json"), + cryptoPath: path.join(stateDir, "credentials", "matrix", "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", + "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..438a16e4243 --- /dev/null +++ b/extensions/matrix-js/src/matrix/client/types.ts @@ -0,0 +1,40 @@ +export type MatrixResolvedConfig = { + homeserver: string; + userId: string; + accessToken?: string; + deviceId?: string; + password?: string; + register?: boolean; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +/** + * Authenticated Matrix configuration. + * Note: deviceId is NOT included here because it's implicit in the accessToken. + * The crypto storage assumes the device ID (and thus access token) does not change + * between restarts. If the access token becomes invalid or crypto storage is lost, + * both will need to be recreated together. + */ +export type MatrixAuth = { + homeserver: string; + userId: string; + accessToken: string; + password?: string; + deviceId?: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +export type MatrixStoragePaths = { + rootDir: string; + storagePath: string; + cryptoPath: string; + metaPath: string; + recoveryKeyPath: string; + idbSnapshotPath: string; + accountKey: string; + tokenHash: string; +}; diff --git a/extensions/matrix-js/src/matrix/credentials.ts b/extensions/matrix-js/src/matrix/credentials.ts new file mode 100644 index 00000000000..7da620324d7 --- /dev/null +++ b/extensions/matrix-js/src/matrix/credentials.ts @@ -0,0 +1,125 @@ +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 { 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"); +} + +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 function saveMatrixCredentials( + credentials: Omit, + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): void { + const dir = resolveMatrixCredentialsDir(env); + fs.mkdirSync(dir, { recursive: true }); + + 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, + }; + + fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); +} + +export function touchMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): void { + const existing = loadMatrixCredentials(env, accountId); + if (!existing) { + return; + } + + existing.lastUsedAt = new Date().toISOString(); + const credPath = resolveMatrixCredentialsPath(env, accountId); + fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); +} + +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..c8218fc200c --- /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"; + +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..754f3ee24f7 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/allowlist.ts @@ -0,0 +1,103 @@ +import type { AllowlistMatch } from "openclaw/plugin-sdk"; + +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..74670becc72 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/auto-join.test.ts @@ -0,0 +1,127 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +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: { + autoJoin: "always", + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk").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: { + autoJoin: "allowlist", + autoJoinAllowlist: ["#allowed:example.org"], + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk").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: { + autoJoin: "allowlist", + autoJoinAllowlist: [" #allowed:example.org "], + }, + }, + }; + + registerMatrixAutoJoin({ + client, + cfg, + runtime: { + log: vi.fn(), + error: vi.fn(), + } as unknown as import("openclaw/plugin-sdk").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..37bf3b27c6d --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/auto-join.ts @@ -0,0 +1,75 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +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?.autoJoin ?? "always"; + const autoJoinAllowlist = new Set( + (cfg.channels?.matrix?.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.ts b/extensions/matrix-js/src/matrix/monitor/events.ts new file mode 100644 index 00000000000..3608991da16 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/events.ts @@ -0,0 +1,101 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { MatrixAuth } from "../client.js"; +import type { MatrixClient } from "../sdk.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +export function registerMatrixMonitorEvents(params: { + client: MatrixClient; + auth: MatrixAuth; + logVerboseMessage: (message: string) => void; + warnedEncryptedRooms: Set; + warnedCryptoMissingRooms: Set; + logger: { warn: (meta: Record, message: string) => void }; + formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; + onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; +}): void { + const { + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint, + onRoomMessage, + } = params; + + client.on("room.message", onRoomMessage); + + 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( + { roomId, eventId: event.event_id, error: error.message }, + "Failed to decrypt 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.encryption=true and verify the device to decrypt"; + logger.warn({ roomId }, warning); + } + 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({ roomId }, warning); + } + 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"}`, + ); + } + }); +} 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..da4a5d9c33f --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/handler.ts @@ -0,0 +1,665 @@ +import { + createReplyPrefixOptions, + createTypingCallbacks, + formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, + resolveControlCommandGate, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, 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"; + +export type MatrixMonitorHandlerParams = { + client: MatrixClient; + core: { + logging: { + shouldLogVerbose: () => boolean; + }; + channel: (typeof import("openclaw/plugin-sdk"))["channel"]; + system: { + enqueueSystemEvent: ( + text: string, + meta: { sessionKey?: string | null; contextKey?: string | null }, + ) => void; + }; + }; + cfg: CoreConfig; + runtime: RuntimeEnv; + logger: { + info: (message: string | Record, ...meta: unknown[]) => void; + warn: (meta: Record, message: string) => void; + }; + logVerboseMessage: (message: string) => void; + allowFrom: string[]; + roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig } + ? MatrixConfig extends { groups?: infer Groups } + ? Groups + : Record | undefined + : Record | undefined; + mentionRegexes: ReturnType< + (typeof import("openclaw/plugin-sdk"))["channel"]["mentions"]["buildMentionRegexes"] + >; + groupPolicy: "open" | "allowlist" | "disabled"; + replyToMode: ReplyToMode; + threadReplies: "off" | "inbound" | "always"; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + textLimit: number; + mediaMaxBytes: number; + startupMs: number; + startupGraceMs: number; + directTracker: { + isDirectMessage: (params: { + roomId: string; + senderId: string; + selfUserId: string; + }) => Promise; + }; + 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, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + } = params; + + 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; + } + } + + 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 core.channel.pairing + .readAllowFromStore("matrix") + .catch(() => []); + const effectiveAllowFrom = normalizeMatrixAllowList([...allowFrom, ...storeAllowFrom]); + const groupAllowFrom = cfg.channels?.matrix?.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", + id: senderId, + meta: { name: senderName }, + }); + if (created) { + logVerboseMessage( + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + { client }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } + 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", + }); + 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", + 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({ roomId, reason: "no-mention" }, "skipping room message"); + 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", + peer: { + kind: isDirectMessage ? "dm" : "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" as const, + Surface: "matrix" 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" 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", + to: `room:${roomId}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logger.warn( + { + error: String(err), + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + }, + "failed updating session meta", + ); + }, + }); + + 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", + accountId: route.accountId, + }); + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg, + agentId: route.agentId, + channel: "matrix", + 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", + action: "start", + target: roomId, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + 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) => { + await deliverMatrixReplies({ + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + accountId: route.accountId, + tableMode, + }); + didSendReply = true; + }, + onError: (err, info) => { + 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..8e4b1ba1c64 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/index.ts @@ -0,0 +1,365 @@ +import { format } from "node:util"; +import { + GROUP_POLICY_BLOCKED_LABEL, + mergeAllowlist, + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + summarizeMapping, + warnMissingProviderGroupPolicyFallbackOnce, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +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 { 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?.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: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.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, + }); + setActiveMatrixClient(client, opts.accountId); + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); + const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); + const { groupPolicy: groupPolicyRaw, providerMissingFallbackApplied } = + resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.matrix !== undefined, + groupPolicy: accountConfig.groupPolicy, + defaultGroupPolicy, + }); + warnMissingProviderGroupPolicyFallbackOnce({ + providerMissingFallbackApplied, + providerKey: "matrix", + 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"); + 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, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + accountId: opts.accountId, + }); + + 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}`); + + // If E2EE is enabled, trigger device verification + if (auth.encryption && client.crypto) { + try { + // Request verification from other sessions + const verificationRequest = await ( + client.crypto as { requestOwnUserVerification?: () => Promise } + ).requestOwnUserVerification?.(); + if (verificationRequest) { + logger.info("matrix: device verification requested - please verify in another client"); + } + } catch (err) { + logger.debug?.("Device verification request failed (may already be verified)", { + error: String(err), + }); + } + } + + 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..7380ec215b4 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/location.ts @@ -0,0 +1,100 @@ +import { + formatLocationText, + toLocationContext, + type NormalizedLocation, +} from "openclaw/plugin-sdk"; +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..dcf7af8ad69 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/media.test.ts @@ -0,0 +1,102 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +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..56210f833c5 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/media.ts @@ -0,0 +1,118 @@ +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; headerType?: string } | 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 result = await params.client.downloadContent(params.mxcUrl); + const raw = result.data ?? result; + const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + + if (buffer.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + return { buffer, headerType: result.contentType }; + } 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 = fetched.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..232e495c88d --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/mentions.ts @@ -0,0 +1,62 @@ +import { getMatrixRuntime } from "../../runtime.js"; + +// Type for room message content with mentions +type MessageContentWithMentions = { + msgtype: string; + body: string; + formatted_body?: string; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; +}; + +/** + * 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: MessageContentWithMentions; + 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..3dda8fac9b5 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/replies.test.ts @@ -0,0 +1,132 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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..575f472f403 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/replies.ts @@ -0,0 +1,100 @@ +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk"; +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", + 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", 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..4a7624d7c10 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/room-info.ts @@ -0,0 +1,55 @@ +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); + name = nameState?.name; + } catch { + // ignore + } + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + canonicalAlias = aliasState?.alias; + altAliases = aliasState?.alt_aliases ?? []; + } 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); + return memberState?.displayname ?? 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..2200ad0c1e4 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/rooms.ts @@ -0,0 +1,47 @@ +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk"; +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..1a2f260f243 --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/threads.ts @@ -0,0 +1,68 @@ +// Type for raw Matrix event payload consumed by thread helpers. +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; +}; + +type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +const RelationType = { + Thread: "m.thread", +} as const; + +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..5d578868f3a --- /dev/null +++ b/extensions/matrix-js/src/matrix/monitor/types.ts @@ -0,0 +1,27 @@ +import type { EncryptedFile, MatrixRawEvent, MessageEventContent } 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/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..aa55a83d681 --- /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"; + +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..fbd05ee1068 --- /dev/null +++ b/extensions/matrix-js/src/matrix/probe.ts @@ -0,0 +1,70 @@ +import type { BaseProbeResult } from "openclaw/plugin-sdk"; +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/sdk.test.ts b/extensions/matrix-js/src/matrix/sdk.test.ts new file mode 100644 index 00000000000..e2c6bdcfdf0 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk.test.ts @@ -0,0 +1,751 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +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", + }, + }); + + let releaseRetry: (() => void) | null = null; + matrixJsClient.decryptEventIfNeeded = vi.fn( + async () => + await new Promise((resolve) => { + releaseRetry = () => { + 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); + releaseRetry?.(); + 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("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("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); + }); +}); diff --git a/extensions/matrix-js/src/matrix/sdk.ts b/extensions/matrix-js/src/matrix/sdk.ts new file mode 100644 index 00000000000..058ab9d77fc --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk.ts @@ -0,0 +1,527 @@ +// 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 { MatrixCryptoBootstrapper } 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, + 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, + MessageEventContent, + TextualMessageEventContent, +} from "./sdk/types.js"; + +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 idbSnapshotPath?: string; + private readonly cryptoDatabasePrefix?: string; + private bridgeRegistered = false; + private started = 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; + + 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; + } = {}, + ) { + 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.idbSnapshotPath = opts.idbSnapshotPath; + this.cryptoDatabasePrefix = opts.cryptoDatabasePrefix; + this.selfUserId = opts.userId?.trim() || null; + 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, + localTimeoutMs: this.localTimeoutMs, + cryptoCallbacks, + 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, + }); + this.started = true; + this.emitOutstandingInviteEvents(); + await this.refreshDmCache().catch(noop); + } + + stop(): void { + if (this.idbPersistTimer) { + clearInterval(this.idbPersistTimer); + this.idbPersistTimer = null; + } + this.decryptBridge.stop(); + // Final persist on shutdown + persistIdbToDisk({ + snapshotPath: this.idbSnapshotPath, + databasePrefix: this.cryptoDatabasePrefix, + }).catch(noop); + this.client.stopClient(); + this.started = false; + } + + 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; + + const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined; + if (crypto) { + await this.cryptoBootstrapper.bootstrap(crypto); + } + + // 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); + 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 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(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, + }); + } + + 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 = { + type: "m.room.member", + room_id: roomId, + 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..a0f79bff94d --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.test.ts @@ -0,0 +1,241 @@ +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("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 firstCall = bootstrapCrossSigning.mock.calls[0]?.[0] as { + authUploadDeviceSigningKeys?: ( + makeRequest: (authData: Record | null) => Promise, + ) => Promise; + }; + expect(firstCall.authUploadDeviceSigningKeys).toBeTypeOf("function"); + + const seenAuthStages: Array | null> = []; + const result = await firstCall.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); + }); +}); 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..e1207b06116 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/crypto-bootstrap.ts @@ -0,0 +1,226 @@ +import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import type { MatrixDecryptBridge } from "./decrypt-bridge.js"; +import { LogService } from "./logger.js"; +import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; +import 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 class MatrixCryptoBootstrapper { + constructor(private readonly deps: MatrixCryptoBootstrapperDeps) {} + + async bootstrap(crypto: MatrixCryptoBootstrapApi): Promise { + await this.bootstrapSecretStorage(crypto); + await this.bootstrapCrossSigning(crypto); + await this.bootstrapSecretStorage(crypto); + await this.ensureOwnDeviceTrust(crypto); + this.registerVerificationRequestHandler(crypto); + } + + private createSigningKeysUiAuthCallback(params: { + userId: string; + password?: string; + }): MatrixUiAuthCallback { + return async (makeRequest: (authData: MatrixAuthDict | null) => Promise): Promise => { + try { + return await makeRequest(null); + } catch { + // Some homeservers require an explicit dummy UIA stage even when no user interaction is needed. + try { + return await makeRequest({ type: "m.login.dummy" }); + } catch { + if (!params.password?.trim()) { + throw new Error( + "Matrix cross-signing key upload requires UIA; provide matrix.password for m.login.password fallback", + ); + } + return await makeRequest({ + type: "m.login.password", + identifier: { type: "m.id.user", user: params.userId }, + password: params.password, + }); + } + } + }; + } + + private async bootstrapCrossSigning(crypto: MatrixCryptoBootstrapApi): Promise { + 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; + } + }; + + // 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); + return; + } + } + + const firstPassReady = await isCrossSigningReady(); + const firstPassPublished = await hasPublishedCrossSigningKeys(); + if (firstPassReady && firstPassPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return; + } + + // 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); + return; + } + + const finalReady = await isCrossSigningReady(); + const finalPublished = await hasPublishedCrossSigningKeys(); + if (finalReady && finalPublished) { + LogService.info("MatrixClientLite", "Cross-signing bootstrap complete"); + return; + } + LogService.warn( + "MatrixClientLite", + "Cross-signing bootstrap finished but server keys are still not published", + ); + } + + private async bootstrapSecretStorage(crypto: MatrixCryptoBootstrapApi): 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); + } + } + + private registerVerificationRequestHandler(crypto: MatrixCryptoBootstrapApi): void { + // Auto-accept incoming verification requests from other users/devices. + crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => { + const verificationRequest = request as MatrixVerificationRequestLike; + this.deps.verificationManager.trackVerificationRequest(verificationRequest); + const otherUserId = verificationRequest.otherUserId; + const isSelfVerification = verificationRequest.isSelfVerification; + const initiatedByMe = verificationRequest.initiatedByMe; + + if (isSelfVerification || initiatedByMe) { + LogService.debug( + "MatrixClientLite", + `Ignoring ${isSelfVerification ? "self" : "initiated"} verification request from ${otherUserId}`, + ); + 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): Promise { + const deviceId = this.deps.getDeviceId()?.trim(); + if (!deviceId) { + return; + } + 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; + } + + 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); + } + } + } +} 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..edaff65a1fd --- /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", "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..866f959e912 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/logger.ts @@ -0,0 +1,57 @@ +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 +} + +export class ConsoleLogger { + trace(module: string, ...messageOrObject: unknown[]): void { + console.debug(`[${module}]`, ...messageOrObject); + } + + debug(module: string, ...messageOrObject: unknown[]): void { + console.debug(`[${module}]`, ...messageOrObject); + } + + info(module: string, ...messageOrObject: unknown[]): void { + console.info(`[${module}]`, ...messageOrObject); + } + + warn(module: string, ...messageOrObject: unknown[]): void { + console.warn(`[${module}]`, ...messageOrObject); + } + + error(module: string, ...messageOrObject: unknown[]): void { + console.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..e16f3c50ebc --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.test.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +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", + }); + }); +}); 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..dec591f825f --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/recovery-key-store.ts @@ -0,0 +1,253 @@ +import fs from "node:fs"; +import path from "node:path"; +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, + }; + } + + async bootstrapSecretStorageWithRecoveryKey(crypto: MatrixCryptoBootstrapApi): 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 = 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: false, + }; + + 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..da5448f3aef --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/types.ts @@ -0,0 +1,183 @@ +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 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; + 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..4d3e24a529f --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.test.ts @@ -0,0 +1,170 @@ +import { EventEmitter } from "node:events"; +import { VerificationPhase } 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("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); + + const sas = manager.getVerificationSas(tracked.id); + expect(sas.decimal).toEqual([111, 222, 333]); + expect(sas.emoji?.length).toBe(3); + + await manager.confirmVerificationSas(tracked.id); + expect(confirm).toHaveBeenCalledTimes(1); + + manager.mismatchVerificationSas(tracked.id); + expect(mismatch).toHaveBeenCalledTimes(1); + }); + + it("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..a9a378aa0b1 --- /dev/null +++ b/extensions/matrix-js/src/matrix/sdk/verification-manager.ts @@ -0,0 +1,464 @@ +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; + 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; + 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 pruneVerificationSessions(nowMs: number): void { + for (const [id, session] of this.verificationSessions) { + const phase = session.request.phase; + 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 = request.phase; + const canAccept = phase < VerificationPhase.Ready && !request.accepting && !request.declining; + return { + id: session.id, + transactionId: request.transactionId, + roomId: request.roomId, + otherUserId: request.otherUserId, + otherDeviceId: request.otherDeviceId, + isSelfVerification: request.isSelfVerification, + initiatedByMe: request.initiatedByMe, + phase, + phaseName: this.getVerificationPhaseName(phase), + pending: request.pending, + methods: Array.isArray(request.methods) ? request.methods : [], + chosenMethod: request.chosenMethod ?? null, + canAccept, + hasSas: Boolean(session.sasCallbacks), + 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()) { + if (session.request.transactionId === 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); + if (session.request.verifier) { + this.attachVerifierToVerificationSession(session, session.request.verifier); + } + }); + } + + private attachVerifierToVerificationSession( + session: MatrixVerificationSession, + verifier: MatrixVerifierLike, + ): void { + session.activeVerifier = verifier; + this.touchVerificationSession(session); + + const maybeSas = verifier.getShowSasCallbacks(); + if (maybeSas) { + session.sasCallbacks = maybeSas; + } + const maybeReciprocateQr = verifier.getReciprocateQrCodeCallbacks(); + if (maybeReciprocateQr) { + session.reciprocateQrCallbacks = maybeReciprocateQr; + } + + const verifierObj = verifier as unknown as object; + if (this.trackedVerificationVerifiers.has(verifierObj)) { + return; + } + this.trackedVerificationVerifiers.add(verifierObj); + + verifier.on(VerifierEvent.ShowSas, (sas) => { + session.sasCallbacks = sas as MatrixShowSasCallbacks; + this.touchVerificationSession(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); + }); + } + + 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 = request.transactionId?.trim(); + if (txId) { + for (const existing of this.verificationSessions.values()) { + if (existing.request.transactionId === txId) { + existing.request = request; + this.ensureVerificationRequestTracked(existing); + if (request.verifier) { + this.attachVerifierToVerificationSession(existing, request.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, + }; + this.verificationSessions.set(session.id, session); + this.ensureVerificationRequestTracked(session); + if (request.verifier) { + this.attachVerifierToVerificationSession(session, request.verifier); + } + 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..b31d73fd5a8 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send.test.ts @@ -0,0 +1,155 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +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(() => { + vi.clearAllMocks(); + 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"); + }); +}); + +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..977c868369c --- /dev/null +++ b/extensions/matrix-js/src/matrix/send.ts @@ -0,0 +1,260 @@ +import type { PollInput } from "openclaw/plugin-sdk"; +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", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(opts.accountId); + 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 }) + : 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.ts b/extensions/matrix-js/src/matrix/send/client.ts new file mode 100644 index 00000000000..7dd15dc1f1b --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/client.ts @@ -0,0 +1,67 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { MatrixClient } from "../sdk.js"; +import type { CoreConfig } from "../types.js"; + +const getCore = () => getMatrixRuntime(); + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export function resolveMediaMaxBytes(): number | undefined { + const cfg = getCore().config.loadConfig() as CoreConfig; + if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { + return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; + } + return undefined; +} + +export async function resolveMatrixClient(opts: { + client?: MatrixClient; + timeoutMs?: number; +}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { + ensureNodeRuntime(); + if (opts.client) { + return { client: opts.client, stopOnDone: false }; + } + const active = getActiveMatrixClient(); + if (active) { + return { client: active, stopOnDone: false }; + } + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth(); + 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, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off sends; normal sync will retry. + } + } + await client.start(); + 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..3d8a4a84089 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/media.ts @@ -0,0 +1,229 @@ +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; +}): Promise { + const meta = await getCore() + .media.getImageMetadata(params.buffer) + .catch(() => null); + if (!meta) { + return undefined; + } + const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; + 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..d0eb736a9b8 --- /dev/null +++ b/extensions/matrix-js/src/matrix/send/targets.ts @@ -0,0 +1,150 @@ +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 | null = null; + try { + directContent = await client.getAccountData(EventType.Direct); + } catch { + // Ignore fetch errors and fall back to an empty map. + } + const existing = directContent && !Array.isArray(directContent) ? directContent : {}; + const current = Array.isArray(existing[userId]) ? existing[userId] : []; + if (current[0] === roomId) { + return; + } + const next = [roomId, ...current.filter((id) => id !== roomId)]; + try { + await client.setAccountData(EventType.Direct, { + ...existing, + [userId]: next, + }); + } catch { + // Ignore persistence errors. + } +} + +async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + 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.ts b/extensions/matrix-js/src/onboarding.ts new file mode 100644 index 00000000000..117e3e14f09 --- /dev/null +++ b/extensions/matrix-js/src/onboarding.ts @@ -0,0 +1,452 @@ +import type { DmPolicy } from "openclaw/plugin-sdk"; +import { + addWildcardAllowFrom, + formatDocsLink, + mergeAllowFromEntries, + promptChannelAccessConfig, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; +import type { CoreConfig } from "./types.js"; + +const channel = "matrix" as const; + +function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { + const allowFrom = + policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Matrix requires a homeserver URL.", + "Use an access token (recommended), password login, or account registration.", + "With access token: user ID is fetched automatically.", + "Password + register mode can create an account on homeservers with open registration.", + "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", + `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, + ].join("\n"), + "Matrix setup", + ); +} + +async function promptMatrixAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; +}): Promise { + const { cfg, prompter } = params; + const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + const account = resolveMatrixAccount({ cfg }); + const canResolve = Boolean(account.configured); + + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (full @user:server; display name only if unique)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + const pending: string[] = []; + const unresolved: string[] = []; + const unresolvedNotes: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + pending.push(part); + } + + if (pending.length > 0) { + const results = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + }).catch(() => []); + for (const result of results) { + if (result?.resolved && result.id) { + resolvedIds.push(result.id); + continue; + } + if (result?.input) { + unresolved.push(result.input); + if (result.note) { + unresolvedNotes.push(`${result.input}: ${result.note}`); + } + } + } + } + + if (unresolved.length > 0) { + const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved; + await prompter.note( + `Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + dm: { + ...cfg.channels?.matrix?.dm, + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; + } +} + +function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + 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: { + ...cfg.channels?.matrix, + enabled: true, + groups, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Matrix", + channel, + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: promptMatrixAllowFrom, +}; + +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 }) => { + let next = cfg as CoreConfig; + await ensureMatrixSdkInstalled({ + runtime, + confirm: async (message) => + await prompter.confirm({ + message, + initialValue: true, + }), + }); + const existing = next.channels?.matrix ?? {}; + const account = resolveMatrixAccount({ cfg: next }); + if (!account.configured) { + await noteMatrixAuthHelp(prompter); + } + + const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); + const envUserId = process.env.MATRIX_USER_ID?.trim(); + const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); + const envPassword = process.env.MATRIX_PASSWORD?.trim(); + const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const useEnv = await prompter.confirm({ + message: "Matrix env vars detected. Use env values?", + initialValue: true, + }); + if (useEnv) { + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + }; + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + return { cfg: next }; + } + } + + const homeserver = String( + await prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!/^https?:\/\//i.test(raw)) { + return "Use a full URL (https://...)"; + } + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = existing.password ?? ""; + let userId = existing.userId ?? ""; + let register = existing.register === true; + + if (accessToken || password) { + const keep = await prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + register = false; + } + } + + if (!accessToken && !password) { + // Ask auth method FIRST before asking for user ID + const authMode = await prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + { + value: "register", + label: "Register account (open homeserver registration required)", + }, + ], + }); + + if (authMode === "token") { + accessToken = String( + await prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + // With access token, we can fetch the userId automatically - don't prompt for it + // The client.ts will use whoami() to get it + userId = ""; + register = false; + } else { + // Password auth and registration mode require user ID upfront + userId = String( + await prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!raw.startsWith("@")) { + return "Matrix user IDs should start with @"; + } + if (!raw.includes(":")) { + return "Matrix user IDs should include a server (:server)"; + } + return undefined; + }, + }), + ).trim(); + password = String( + await prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + register = authMode === "register"; + } + } + + const deviceName = String( + await prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + // Ask about E2EE encryption + const enableEncryption = await prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + homeserver, + userId: userId || undefined, + accessToken: accessToken || undefined, + password: password || undefined, + register, + deviceName: deviceName || undefined, + encryption: enableEncryption || undefined, + }, + }, + }; + + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + + const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await 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 prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixGroupRooms(next, roomKeys); + } + } + + return { cfg: next }; + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false }, + }, + }), +}; diff --git a/extensions/matrix-js/src/outbound.ts b/extensions/matrix-js/src/outbound.ts new file mode 100644 index 00000000000..5ad3afbaf03 --- /dev/null +++ b/extensions/matrix-js/src/outbound.ts @@ -0,0 +1,55 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +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", + 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", + 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", + 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..3d6310534f8 --- /dev/null +++ b/extensions/matrix-js/src/resolve-targets.test.ts @@ -0,0 +1,67 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +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"); + }); +}); diff --git a/extensions/matrix-js/src/resolve-targets.ts b/extensions/matrix-js/src/resolve-targets.ts new file mode 100644 index 00000000000..fb111da0c74 --- /dev/null +++ b/extensions/matrix-js/src/resolve-targets.ts @@ -0,0 +1,126 @@ +import type { + ChannelDirectoryEntry, + ChannelResolveKind, + ChannelResolveResult, + RuntimeEnv, +} from "openclaw/plugin-sdk"; +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[] = []; + 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 listMatrixDirectoryPeersLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + 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 listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + 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..62eff71ad17 --- /dev/null +++ b/extensions/matrix-js/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +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..0a37784af51 --- /dev/null +++ b/extensions/matrix-js/src/tool-actions.ts @@ -0,0 +1,294 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "openclaw/plugin-sdk"; +import { + acceptMatrixVerification, + cancelMatrixVerification, + confirmMatrixVerificationReciprocateQr, + confirmMatrixVerificationSas, + deleteMatrixMessage, + editMatrixMessage, + generateMatrixVerificationQr, + getMatrixEncryptionStatus, + getMatrixMemberInfo, + getMatrixRoomInfo, + getMatrixVerificationSas, + listMatrixPins, + listMatrixReactions, + listMatrixVerifications, + mismatchMatrixVerificationSas, + pinMatrixMessage, + readMatrixMessages, + requestMatrixVerification, + removeMatrixReactions, + scanMatrixVerificationQr, + sendMatrixMessage, + startMatrixVerification, + unpinMatrixMessage, +} 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", +]); + +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 isActionEnabled = createActionGate(cfg.channels?.matrix?.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 }); + return jsonResult({ ok: true, status }); + } + if (action === "verificationList") { + const verifications = await listMatrixVerifications(); + 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, + }); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationAccept") { + const verification = await acceptMatrixVerification( + readStringParam({ requestId }, "requestId", { required: true }), + ); + 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 }, + ); + 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" }, + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationGenerateQr") { + const qr = await generateMatrixVerificationQr( + readStringParam({ requestId }, "requestId", { required: true }), + ); + 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 }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationSas") { + const sas = await getMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, sas }); + } + if (action === "verificationConfirm") { + const verification = await confirmMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationMismatch") { + const verification = await mismatchMatrixVerificationSas( + readStringParam({ requestId }, "requestId", { required: true }), + ); + return jsonResult({ ok: true, verification }); + } + if (action === "verificationConfirmQr") { + const verification = await confirmMatrixVerificationReciprocateQr( + readStringParam({ requestId }, "requestId", { required: true }), + ); + 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..fc373d6acdb --- /dev/null +++ b/extensions/matrix-js/src/types.ts @@ -0,0 +1,121 @@ +import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk"; +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; + /** Auto-register account when password login fails (open registration homeservers). */ + register?: boolean; + /** Optional Matrix device id (recommended when using access tokens + E2EE). */ + deviceId?: string; + /** Optional device name when logging in via password. */ + deviceName?: string; + /** Initial sync limit for startup (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?: MatrixConfig; + defaults?: { + groupPolicy?: "open" | "allowlist" | "disabled"; + }; + }; + commands?: { + useAccessGroups?: boolean; + }; + session?: { + store?: string; + }; + messages?: { + ackReaction?: string; + ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all"; + }; + [key: string]: unknown; +};