diff --git a/docs/channels/location.md b/docs/channels/location.md index f38031fb6..c3742c63a 100644 --- a/docs/channels/location.md +++ b/docs/channels/location.md @@ -14,6 +14,7 @@ Clawdbot normalizes shared locations from chat channels into: Currently supported: - **Telegram** (location pins + venues + live locations) - **WhatsApp** (locationMessage + liveLocationMessage) +- **Matrix** (`m.location` with `geo_uri`) ## Text formatting Locations are rendered as friendly lines without brackets: @@ -44,3 +45,4 @@ When a location is present, these fields are added to `ctx`: ## Channel notes - **Telegram**: venues map to `LocationName/LocationAddress`; live locations use `live_period`. - **WhatsApp**: `locationMessage.comment` and `liveLocationMessage.caption` are appended as the caption line. +- **Matrix**: `geo_uri` is parsed as a pin location; altitude is ignored and `LocationIsLive` is always false. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 02764e2e6..432772c16 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -5,17 +5,26 @@ read_when: --- # Matrix (plugin) -Status: supported via plugin (matrix-js-sdk). Direct messages, rooms, threads, media, reactions, and polls. +Matrix is an open, decentralized messaging protocol. Clawdbot connects as a Matrix **user** +on any homeserver, so you need a Matrix account for the bot. Once it is logged in, you can DM +the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, +but it requires E2EE to be enabled. + +Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, +polls (send + poll-start as text), location, and E2EE (with crypto support). ## Plugin required + Matrix ships as a plugin and is not bundled with the core install. Install via CLI (npm registry): + ```bash clawdbot plugins install @clawdbot/matrix ``` Local checkout (when running from a git repo): + ```bash clawdbot plugins install ./extensions/matrix ``` @@ -25,27 +34,54 @@ Clawdbot will offer the local install path automatically. Details: [Plugins](/plugin) -## Quick setup (beginner) +## Setup + 1) Install the Matrix plugin: - From npm: `clawdbot plugins install @clawdbot/matrix` - From a local checkout: `clawdbot plugins install ./extensions/matrix` -2) Configure credentials: - - Env: `MATRIX_HOMESERVER`, `MATRIX_USER_ID`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_PASSWORD`) +2) Create a Matrix account on a homeserver: + - Browse hosting options at [https://matrix.org/ecosystem/hosting/](https://matrix.org/ecosystem/hosting/) + - Or host it yourself. +3) Get an access token for the bot account: + - Use the Matrix login API with `curl` at your home server: + + ```bash + curl --request POST \ + --url https://matrix.example.org/_matrix/client/v3/login \ + --header 'Content-Type: application/json' \ + --data '{ + "type": "m.login.password", + "identifier": { + "type": "m.id.user", + "user": "your-user-name" + }, + "password": "your-password" + }' + ``` + + - Replace `matrix.example.org` with your homeserver URL. + - Or set `channels.matrix.userId` + `channels.matrix.password`: Clawdbot calls the same + login endpoint, stores the access token in `~/.clawdbot/credentials/matrix/credentials.json`, + and reuses it on next start. +4) Configure credentials: + - Env: `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (or `MATRIX_USER_ID` + `MATRIX_PASSWORD`) - Or config: `channels.matrix.*` - If both are set, config takes precedence. -3) Restart the gateway (or finish onboarding). -4) DM access defaults to pairing; approve the pairing code on first contact. + - With access token: user ID is fetched automatically via `/whoami`. + - When set, `channels.matrix.userId` should be the full Matrix ID (example: `@bot:example.org`). +5) Restart the gateway (or finish onboarding). +6) Start a DM with the bot or invite it to a room from any Matrix client + (Element, Beeper, etc.; see https://matrix.org/ecosystem/clients/). Beeper requires E2EE, + so set `channels.matrix.encryption: true` and verify the device. -Runtime note: Matrix requires Node.js (Bun is not supported). +Minimal config (access token, user ID auto-fetched): -Minimal config: ```json5 { channels: { matrix: { enabled: true, homeserver: "https://matrix.example.org", - userId: "@clawdbot:example.org", accessToken: "syt_***", dm: { policy: "pairing" } } @@ -53,18 +89,49 @@ Minimal config: } ``` -## Encryption (E2EE) -End-to-end encrypted rooms are **not** supported. -- Use unencrypted rooms or disable encryption when creating the room. -- If a room is E2EE, the bot will receive encrypted events and won’t reply. +E2EE config (end to end encryption enabled): -## What it is -Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and listens to DMs and rooms. -- A Matrix user account owned by the Gateway. -- Deterministic routing: replies go back to Matrix. +```json5 +{ + channels: { + matrix: { + enabled: true, + homeserver: "https://matrix.example.org", + accessToken: "syt_***", + encryption: true, + dm: { policy: "pairing" } + } + } +} +``` + +## Encryption (E2EE) + +End-to-end encryption is **supported** via the Rust crypto SDK. + +Enable with `channels.matrix.encryption: true`: + +- If the crypto module loads, encrypted rooms are decrypted automatically. +- Outbound media is encrypted when sending to encrypted rooms. +- On first connection, Clawdbot requests device verification from your other sessions. +- Verify the device in another Matrix client (Element, etc.) to enable key sharing. +- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt; + Clawdbot logs a warning. + +Crypto state is stored in `~/.clawdbot/matrix/crypto/` (SQLite database). + +**Device verification:** +When E2EE is enabled, the bot will request verification from your other sessions on startup. +Open Element (or another client) and approve the verification request to establish trust. +Once verified, the bot can decrypt messages in encrypted rooms. + +## Routing model + +- Replies always go back to Matrix. - DMs share the agent's main session; rooms map to group sessions. ## Access control (DMs) + - Default: `channels.matrix.dm.policy = "pairing"`. Unknown senders get a pairing code. - Approve via: - `clawdbot pairing list matrix` @@ -73,58 +140,80 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis - `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available. ## Rooms (groups) + - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. -- Allowlist rooms with `channels.matrix.rooms`: +- Allowlist rooms with `channels.matrix.groups` (room IDs, aliases, or names): + ```json5 { channels: { matrix: { - rooms: { - "!roomId:example.org": { requireMention: true } - } + groupPolicy: "allowlist", + groups: { + "!roomId:example.org": { allow: true }, + "#alias:example.org": { allow: true } + }, + groupAllowFrom: ["@owner:example.org"] } } } ``` + - `requireMention: false` enables auto-reply in that room. +- `groups."*"` can set defaults for mention gating across rooms. +- `groupAllowFrom` restricts which senders can trigger the bot in rooms (optional). +- Per-room `users` allowlists can further restrict senders inside a specific room. - The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible. - On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- Invites are auto-joined by default; control with `channels.matrix.autoJoin` and `channels.matrix.autoJoinAllowlist`. - To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). +- Legacy key: `channels.matrix.rooms` (same shape as `groups`). ## Threads + - Reply threading is supported. -- `channels.matrix.replyToMode` controls replies when tagged: +- `channels.matrix.threadReplies` controls whether replies stay in threads: + - `off`, `inbound` (default), `always` +- `channels.matrix.replyToMode` controls reply-to metadata when not replying in a thread: - `off` (default), `first`, `all` ## Capabilities + | Feature | Status | |---------|--------| | Direct messages | ✅ Supported | | Rooms | ✅ Supported | | Threads | ✅ Supported | | Media | ✅ Supported | -| Reactions | ✅ Supported | -| Polls | ✅ Supported | +| E2EE | ✅ Supported (crypto module required) | +| Reactions | ✅ Supported (send/read via tools) | +| Polls | ✅ Send supported; inbound poll starts are converted to text (responses/ends ignored) | +| Location | ✅ Supported (geo URI; altitude ignored) | | Native commands | ✅ Supported | ## Configuration reference (Matrix) + Full configuration: [Configuration](/gateway/configuration) Provider options: + - `channels.matrix.enabled`: enable/disable channel startup. - `channels.matrix.homeserver`: homeserver URL. -- `channels.matrix.userId`: Matrix user ID. +- `channels.matrix.userId`: Matrix user ID (optional with access token). - `channels.matrix.accessToken`: access token. - `channels.matrix.password`: password for login (token stored). - `channels.matrix.deviceName`: device display name. +- `channels.matrix.encryption`: enable E2EE (default: false). - `channels.matrix.initialSyncLimit`: initial sync limit. - `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound). - `channels.matrix.textChunkLimit`: outbound text chunk size (chars). - `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible. - `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist). +- `channels.matrix.groupAllowFrom`: allowlisted senders for group messages. - `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms. -- `channels.matrix.rooms`: per-room settings and allowlist. +- `channels.matrix.groups`: group allowlist + per-room settings map. +- `channels.matrix.rooms`: legacy group allowlist/config. - `channels.matrix.replyToMode`: reply-to mode for threads/tags. - `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always). diff --git a/docs/concepts/groups.md b/docs/concepts/groups.md index f64b5bd8a..d6e72aac8 100644 --- a/docs/concepts/groups.md +++ b/docs/concepts/groups.md @@ -149,6 +149,14 @@ Control how group/room messages are handled per channel: slack: { groupPolicy: "allowlist", channels: { "#general": { allow: true } } + }, + matrix: { + groupPolicy: "allowlist", + groupAllowFrom: ["@owner:example.org"], + groups: { + "!roomId:example.org": { allow: true }, + "#alias:example.org": { allow: true } + } } } } @@ -165,6 +173,7 @@ Notes: - WhatsApp/Telegram/Signal/iMessage/Microsoft Teams: use `groupAllowFrom` (fallback: explicit `allowFrom`). - Discord: allowlist uses `channels.discord.guilds..channels`. - Slack: allowlist uses `channels.slack.channels`. +- Matrix: allowlist uses `channels.matrix.groups` (room IDs, aliases, or names). Use `channels.matrix.groupAllowFrom` to restrict senders; per-room `users` allowlists are also supported. - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 7cb454ff7..e0e3fdf17 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -24,8 +24,10 @@ } }, "dependencies": { + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "clawdbot": "workspace:*", "markdown-it": "14.1.0", - "matrix-js-sdk": "40.0.0" + "matrix-bot-sdk": "0.8.0", + "music-metadata": "^11.10.6" } } diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index 4d004134f..c83f7121b 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -1,4 +1,3 @@ -import os from "node:os"; import { beforeEach, describe, expect, it } from "vitest"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; @@ -11,7 +10,7 @@ describe("matrix directory", () => { beforeEach(() => { setMatrixRuntime({ state: { - resolveStateDir: () => os.tmpdir(), + resolveStateDir: (_env, homeDir) => homeDir(), }, } as PluginRuntime); }); @@ -21,7 +20,8 @@ describe("matrix directory", () => { channels: { matrix: { dm: { allowFrom: ["matrix:@alice:example.org", "bob"] }, - rooms: { + groupAllowFrom: ["@dana:example.org"], + groups: { "!room1:example.org": { users: ["@carol:example.org"] }, "#alias:example.org": { users: [] }, }, @@ -40,6 +40,7 @@ describe("matrix directory", () => { { 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" }, ]), ); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 3d65152ee..d77f804ba 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -46,10 +46,12 @@ const meta = { function normalizeMatrixMessagingTarget(raw: string): string | undefined { let normalized = raw.trim(); if (!normalized) return undefined; - if (normalized.toLowerCase().startsWith("matrix:")) { + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("matrix:")) { normalized = normalized.slice("matrix:".length).trim(); } - return normalized ? normalized.toLowerCase() : undefined; + const stripped = normalized.replace(/^(room|channel|user):/i, "").trim(); + return stripped || undefined; } function buildMatrixConfigUpdate( @@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin = { }), collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; - const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupPolicy = + account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; return [ - "- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.", + "- 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.", ]; }, }, @@ -168,6 +171,17 @@ export const matrixPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.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, @@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin = { ids.add(raw.replace(/^matrix:/i, "")); } - for (const room of Object.values(account.config.rooms ?? {})) { + 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; @@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin = { listGroups: async ({ cfg, accountId, query, limit }) => { const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); const q = query?.trim().toLowerCase() || ""; - const ids = Object.keys(account.config.rooms ?? {}) + 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, "")) @@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin = { validateInput: ({ input }) => { if (input.useEnv) return null; if (!input.homeserver?.trim()) return "Matrix requires --homeserver"; - if (!input.userId?.trim()) return "Matrix requires --user-id"; - if (!input.accessToken?.trim() && !input.password?.trim()) { + 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 }) => { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 04966621d..3cb396883 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({ password: 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(), @@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({ 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/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts index ee16b713c..5c6aecb5b 100644 --- a/extensions/matrix/src/group-mentions.ts +++ b/extensions/matrix/src/group-mentions.ts @@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b const aliases = groupChannel ? [groupChannel] : []; const cfg = params.cfg as CoreConfig; const resolved = resolveMatrixRoomConfig({ - rooms: cfg.channels?.matrix?.rooms, + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, roomId, aliases, name: groupChannel || undefined, diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts new file mode 100644 index 000000000..2f1cfdb10 --- /dev/null +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -0,0 +1,83 @@ +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/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index d451a58d7..8c95c3f1a 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -31,18 +31,20 @@ export function resolveMatrixAccount(params: { const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig; const enabled = base.enabled !== false; const resolved = resolveMatrixConfig(params.cfg, process.env); - const hasCore = Boolean(resolved.homeserver && resolved.userId); - const hasToken = Boolean(resolved.accessToken || resolved.password); + 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); const hasStored = - stored && - resolved.homeserver && - resolved.userId && - credentialsMatchConfig(stored, { - homeserver: resolved.homeserver, - userId: resolved.userId, - }); - const configured = hasCore && (hasToken || Boolean(hasStored)); + stored && resolved.homeserver + ? credentialsMatchConfig(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + : false; + const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored)); return { accountId, enabled, diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 4c95b936e..e1ad0bdab 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -1,19 +1,4 @@ -import type { MatrixClient, MatrixEvent } from "matrix-js-sdk"; -import { - Direction, - EventType, - MatrixError, - MsgType, - RelationType, -} from "matrix-js-sdk"; -import type { - ReactionEventContent, - RoomMessageEventContent, -} from "matrix-js-sdk/lib/@types/events.js"; -import type { - RoomPinnedEventsEventContent, - RoomTopicEventContent, -} from "matrix-js-sdk/lib/@types/state_events.js"; +import type { MatrixClient } from "matrix-bot-sdk"; import { getMatrixRuntime } from "../runtime.js"; import type { CoreConfig } from "../types.js"; @@ -23,7 +8,6 @@ import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient, - waitForMatrixSync, } from "./client.js"; import { reactMatrixMessage, @@ -31,6 +15,62 @@ import { sendMessageMatrix, } from "./send.js"; +// Constants that were previously from matrix-js-sdk +const MsgType = { + Text: "m.text", +} as const; + +const RelationType = { + Replace: "m.replace", + Annotation: "m.annotation", +} as const; + +const EventType = { + RoomMessage: "m.room.message", + RoomPinnedEvents: "m.room.pinned_events", + RoomTopic: "m.room.topic", + Reaction: "m.reaction", +} as const; + +// Type definitions for matrix-bot-sdk event content +type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.new_content"?: RoomMessageEventContent; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +type ReactionEventContent = { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +}; + +type RoomPinnedEventsEventContent = { + pinned: string[]; +}; + +type RoomTopicEventContent = { + topic?: string; +}; + +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + redacted_because?: unknown; + }; +}; + export type MatrixActionClientOpts = { client?: MatrixClient; timeoutMs?: number; @@ -86,19 +126,15 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise(); +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; @@ -118,27 +154,28 @@ function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary { } : undefined; return { - eventId: event.getId() ?? undefined, - sender: event.getSender() ?? undefined, + eventId: event.event_id, + sender: event.sender, body: content.body, msgtype: content.msgtype, - timestamp: event.getTs() ?? undefined, + timestamp: event.origin_server_ts, relatesTo, }; } async function readPinnedEvents(client: MatrixClient, roomId: string): Promise { try { - const content = (await client.getStateEvent( + const content = (await client.getRoomStateEvent( roomId, EventType.RoomPinnedEvents, "", )) as RoomPinnedEventsEventContent; const pinned = content.pinned; return pinned.filter((id) => id.trim().length > 0); - } catch (err) { - const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined; - const errcode = err instanceof MatrixError ? err.errcode : undefined; + } 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 []; } @@ -151,11 +188,14 @@ async function fetchEventSummary( roomId: string, eventId: string, ): Promise { - const raw = await client.fetchRoomEvent(roomId, eventId); - const mapper = client.getEventMapper(); - const event = mapper(raw); - if (event.isRedacted()) return null; - return summarizeMatrixEvent(event); + try { + const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent; + if (raw.unsigned?.redacted_because) return null; + return summarizeMatrixRawEvent(raw); + } catch (err) { + // Event not found, redacted, or inaccessible - return null + return null; + } } export async function sendMatrixMessage( @@ -200,10 +240,10 @@ export async function editMatrixMessage( event_id: messageId, }, }; - const response = await client.sendMessage(resolvedRoom, payload); - return { eventId: response.event_id ?? null }; + const eventId = await client.sendMessage(resolvedRoom, payload); + return { eventId: eventId ?? null }; } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -215,11 +255,9 @@ export async function deleteMatrixMessage( const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - await client.redactEvent(resolvedRoom, messageId, undefined, { - reason: opts.reason, - }); + await client.redactEvent(resolvedRoom, messageId, opts.reason); } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -242,22 +280,25 @@ export async function readMatrixMessages( typeof opts.limit === "number" && Number.isFinite(opts.limit) ? Math.max(1, Math.floor(opts.limit)) : 20; - const token = opts.before?.trim() || opts.after?.trim() || null; - const dir = opts.after ? Direction.Forward : Direction.Backward; - const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir); - const mapper = client.getEventMapper(); - const events = res.chunk.map(mapper); - const messages = events - .filter((event) => event.getType() === EventType.RoomMessage) - .filter((event) => !event.isRedacted()) - .map(summarizeMatrixEvent); + const token = opts.before?.trim() || opts.after?.trim() || undefined; + const dir = opts.after ? "f" : "b"; + // matrix-bot-sdk uses doRequest for room messages + 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.stopClient(); + if (stopOnDone) client.stop(); } } @@ -273,19 +314,18 @@ export async function listMatrixReactions( typeof opts.limit === "number" && Number.isFinite(opts.limit) ? Math.max(1, Math.floor(opts.limit)) : 100; - const res = await client.relations( - resolvedRoom, - messageId, - RelationType.Annotation, - EventType.Reaction, - { dir: Direction.Backward, limit }, - ); + // matrix-bot-sdk uses doRequest for relations + 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.events) { - const content = event.getContent(); - const key = content["m.relates_to"].key; + 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.getSender() ?? ""; + const sender = event.sender ?? ""; const entry: MatrixReactionSummary = summaries.get(key) ?? { key, count: 0, @@ -299,7 +339,7 @@ export async function listMatrixReactions( } return Array.from(summaries.values()); } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -311,30 +351,28 @@ export async function removeMatrixReactions( const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const res = await client.relations( - resolvedRoom, - messageId, - RelationType.Annotation, - EventType.Reaction, - { dir: Direction.Backward, limit: 200 }, - ); - const userId = client.getUserId(); + 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.events - .filter((event) => event.getSender() === userId) + const toRemove = res.chunk + .filter((event) => event.sender === userId) .filter((event) => { if (!targetEmoji) return true; - const content = event.getContent(); - return content["m.relates_to"].key === targetEmoji; + const content = event.content as ReactionEventContent; + return content["m.relates_to"]?.key === targetEmoji; }) - .map((event) => event.getId()) + .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.stopClient(); + if (stopOnDone) client.stop(); } } @@ -349,10 +387,10 @@ export async function pinMatrixMessage( const current = await readPinnedEvents(client, resolvedRoom); const next = current.includes(messageId) ? current : [...current, messageId]; const payload: RoomPinnedEventsEventContent = { pinned: next }; - await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload); + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); return { pinned: next }; } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -367,10 +405,10 @@ export async function unpinMatrixMessage( const current = await readPinnedEvents(client, resolvedRoom); const next = current.filter((id) => id !== messageId); const payload: RoomPinnedEventsEventContent = { pinned: next }; - await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload); + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); return { pinned: next }; } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -395,7 +433,7 @@ export async function listMatrixPins( ).filter((event): event is MatrixMessageSummary => Boolean(event)); return { pinned, events }; } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } @@ -406,20 +444,23 @@ export async function getMatrixMemberInfo( const { client, stopOnDone } = await resolveActionClient(opts); try { const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; - const profile = await client.getProfileInfo(userId); - const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined; + // matrix-bot-sdk uses getUserProfile + const profile = await client.getUserProfile(userId); + // Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk + // We'd need to fetch room state separately if needed return { userId, profile: { displayName: profile?.displayname ?? null, avatarUrl: profile?.avatar_url ?? null, }, - membership: member?.membership ?? null, - powerLevel: member?.powerLevel ?? null, - displayName: member?.name ?? 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.stopClient(); + if (stopOnDone) client.stop(); } } @@ -427,20 +468,42 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const room = client.getRoom(resolvedRoom); - const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, ""); - const topicContent = topicEvent?.getContent(); - const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined; + // matrix-bot-sdk uses getRoomState for state events + 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 = nameState?.name ?? null; + } catch { /* ignore */ } + + try { + const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, ""); + topic = topicState?.topic ?? null; + } catch { /* ignore */ } + + try { + const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", ""); + canonicalAlias = aliasState?.alias ?? null; + } catch { /* ignore */ } + + try { + const members = await client.getJoinedRoomMembers(resolvedRoom); + memberCount = members.length; + } catch { /* ignore */ } + return { roomId: resolvedRoom, - name: room?.name ?? null, - topic: topic ?? null, - canonicalAlias: room?.getCanonicalAlias?.() ?? null, - altAliases: room?.getAltAliases?.() ?? [], - memberCount: room?.getJoinedMemberCount?.() ?? null, + name, + topic, + canonicalAlias, + altAliases: [], // Would need separate query + memberCount, }; } finally { - if (stopOnDone) client.stopClient(); + if (stopOnDone) client.stop(); } } diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts index 2befe15b6..9aa0ffdde 100644 --- a/extensions/matrix/src/matrix/active-client.ts +++ b/extensions/matrix/src/matrix/active-client.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-js-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; let activeClient: MatrixClient | null = null; diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index 547d0d981..f806f9c81 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => { password: "cfg-pass", deviceName: "CfgDevice", initialSyncLimit: 5, + encryption: false, }); }); @@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => { expect(resolved.password).toBe("env-pass"); expect(resolved.deviceName).toBe("EnvDevice"); expect(resolved.initialSyncLimit).toBeUndefined(); + expect(resolved.encryption).toBe(false); }); }); diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 6493d0a82..8923e784c 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -1,4 +1,11 @@ -import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk"; +import { + ConsoleLogger, + LogService, + MatrixClient, + SimpleFsStorageProvider, + RustSdkCryptoStorageProvider, +} from "matrix-bot-sdk"; +import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk"; import type { CoreConfig } from "../types.js"; import { getMatrixRuntime } from "../runtime.js"; @@ -10,22 +17,30 @@ export type MatrixResolvedConfig = { password?: string; deviceName?: string; initialSyncLimit?: number; + encryption?: boolean; }; +/** + * Authenticated Matrix configuration. + * Note: deviceId is NOT included here because it's implicit in the accessToken. + * The crypto storage assumes the device ID (and thus access token) does not change + * between restarts. If the access token becomes invalid or crypto storage is lost, + * both will need to be recreated together. + */ export type MatrixAuth = { homeserver: string; userId: string; accessToken: string; deviceName?: string; initialSyncLimit?: number; + encryption?: boolean; }; -type MatrixSdk = typeof import("matrix-js-sdk"); - type SharedMatrixClientState = { client: MatrixClient; key: string; started: boolean; + cryptoReady: boolean; }; let sharedClientState: SharedMatrixClientState | null = null; @@ -37,14 +52,65 @@ export function isBunRuntime(): boolean { return typeof versions.bun === "string"; } -async function loadMatrixSdk(): Promise { - return (await import("matrix-js-sdk")) as MatrixSdk; +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"; + }); +} + +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); + }, + }); } function clean(value?: string): string { return value?.trim() ?? ""; } +function sanitizeUserIdList(input: unknown, label: string): string[] { + if (input == null) return []; + if (!Array.isArray(input)) { + LogService.warn( + "MatrixClientLite", + `Expected ${label} list to be an array, got ${typeof input}`, + ); + return []; + } + const filtered = input.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); + if (filtered.length !== input.length) { + LogService.warn( + "MatrixClientLite", + `Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`, + ); + } + return filtered; +} + export function resolveMatrixConfig( cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, env: NodeJS.ProcessEnv = process.env, @@ -61,6 +127,7 @@ export function resolveMatrixConfig( typeof matrix.initialSyncLimit === "number" ? Math.max(0, Math.floor(matrix.initialSyncLimit)) : undefined; + const encryption = matrix.encryption ?? false; return { homeserver, userId, @@ -68,9 +135,26 @@ export function resolveMatrixConfig( password, deviceName, initialSyncLimit, + encryption, }; } +export function resolveCryptoStorePath(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = getMatrixRuntime().state.resolveStateDir(env, () => + require("node:os").homedir(), + ); + const path = require("node:path"); + return path.join(stateDir, "matrix", "crypto"); +} + +export function resolveStoragePath(env: NodeJS.ProcessEnv = process.env): string { + const stateDir = getMatrixRuntime().state.resolveStateDir(env, () => + require("node:os").homedir(), + ); + const path = require("node:path"); + return path.join(stateDir, "matrix", "bot-storage.json"); +} + export async function resolveMatrixAuth(params?: { cfg?: CoreConfig; env?: NodeJS.ProcessEnv; @@ -81,9 +165,6 @@ export async function resolveMatrixAuth(params?: { if (!resolved.homeserver) { throw new Error("Matrix homeserver is required (matrix.homeserver)"); } - if (!resolved.userId) { - throw new Error("Matrix userId is required (matrix.userId)"); - } const { loadMatrixCredentials, @@ -97,21 +178,36 @@ export async function resolveMatrixAuth(params?: { cached && credentialsMatchConfig(cached, { homeserver: resolved.homeserver, - userId: resolved.userId, + userId: resolved.userId || "", }) ? cached : null; + // If we have an access token, we can fetch userId via whoami if not provided if (resolved.accessToken) { - if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + let userId = resolved.userId; + if (!userId) { + // Fetch userId from access token via whoami + ensureMatrixSdkLoggingConfigured(); + const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); + const whoami = await tempClient.getUserId(); + userId = whoami; + // Save the credentials with the fetched userId + saveMatrixCredentials({ + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }); + } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { touchMatrixCredentials(env); } return { homeserver: resolved.homeserver, - userId: resolved.userId, + userId, accessToken: resolved.accessToken, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, }; } @@ -123,25 +219,45 @@ export async function resolveMatrixAuth(params?: { accessToken: cachedCredentials.accessToken, 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 access token or password is required (matrix.accessToken or matrix.password)", + "Matrix password is required when no access token is configured (matrix.password)", ); } - const sdk = await loadMatrixSdk(); - const loginClient = sdk.createClient({ - baseUrl: resolved.homeserver, - }); - const login = await loginClient.loginRequest({ - type: "m.login.password", - identifier: { type: "m.id.user", user: resolved.userId }, - password: resolved.password, - initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway", + // Login with password using HTTP API + const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway", + }), }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + throw new Error(`Matrix login failed: ${errorText}`); + } + + const login = (await loginResponse.json()) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + const accessToken = login.access_token?.trim(); if (!accessToken) { throw new Error("Matrix login did not return an access token"); @@ -153,12 +269,14 @@ export async function resolveMatrixAuth(params?: { accessToken, deviceName: resolved.deviceName, initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, }; saveMatrixCredentials({ homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + deviceId: login.device_id, }); return auth; @@ -168,21 +286,79 @@ export async function createMatrixClient(params: { homeserver: string; userId: string; accessToken: string; + encryption?: boolean; localTimeoutMs?: number; }): Promise { - const sdk = await loadMatrixSdk(); - const store = new sdk.MemoryStore(); - return sdk.createClient({ - baseUrl: params.homeserver, - userId: params.userId, - accessToken: params.accessToken, - localTimeoutMs: params.localTimeoutMs, - store, - }); + ensureMatrixSdkLoggingConfigured(); + const env = process.env; + + // Create storage provider + const storagePath = resolveStoragePath(env); + const fs = await import("node:fs"); + const path = await import("node:path"); + fs.mkdirSync(path.dirname(storagePath), { recursive: true }); + const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath); + + // Create crypto storage if encryption is enabled + let cryptoStorage: ICryptoStorageProvider | undefined; + if (params.encryption) { + const cryptoPath = resolveCryptoStorePath(env); + fs.mkdirSync(cryptoPath, { recursive: true }); + + try { + const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); + cryptoStorage = new RustSdkCryptoStorageProvider(cryptoPath, StoreType.Sqlite); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err); + } + } + + const client = new MatrixClient( + params.homeserver, + params.accessToken, + storage, + cryptoStorage, + ); + + if (client.crypto) { + const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto); + client.crypto.updateSyncData = async ( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + changedDeviceLists, + leftDeviceLists, + ) => { + const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list"); + const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list"); + try { + return await originalUpdateSyncData( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + safeChanged, + safeLeft, + ); + } catch (err) { + const message = typeof err === "string" ? err : err instanceof Error ? err.message : ""; + if (message.includes("Expect value to be String")) { + LogService.warn( + "MatrixClientLite", + "Ignoring malformed device list entries during crypto sync", + message, + ); + return; + } + throw err; + } + }; + } + + return client; } function buildSharedClientKey(auth: MatrixAuth): string { - return [auth.homeserver, auth.userId, auth.accessToken].join("|"); + return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|"); } async function createSharedMatrixClient(params: { @@ -193,15 +369,22 @@ async function createSharedMatrixClient(params: { homeserver: params.auth.homeserver, userId: params.auth.userId, accessToken: params.auth.accessToken, + encryption: params.auth.encryption, localTimeoutMs: params.timeoutMs, }); - return { client, key: buildSharedClientKey(params.auth), started: false }; + return { + client, + key: buildSharedClientKey(params.auth), + started: false, + cryptoReady: false, + }; } async function ensureSharedClientStarted(params: { state: SharedMatrixClientState; timeoutMs?: number; initialSyncLimit?: number; + encryption?: boolean; }): Promise { if (params.state.started) return; if (sharedClientStartPromise) { @@ -209,18 +392,22 @@ async function ensureSharedClientStarted(params: { return; } sharedClientStartPromise = (async () => { - const startOpts: Parameters[0] = { - lazyLoadMembers: true, - threadSupport: true, - }; - if (typeof params.initialSyncLimit === "number") { - startOpts.initialSyncLimit = params.initialSyncLimit; + 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 params.state.client.startClient(startOpts); - await waitForMatrixSync({ - client: params.state.client, - timeoutMs: params.timeoutMs, - }); + + await client.start(); params.state.started = true; })(); try { @@ -249,6 +436,7 @@ export async function resolveSharedMatrixClient( state: sharedClientState, timeoutMs: params.timeoutMs, initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, }); } return sharedClientState.client; @@ -262,11 +450,12 @@ export async function resolveSharedMatrixClient( state: pending, timeoutMs: params.timeoutMs, initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, }); } return pending.client; } - pending.client.stopClient(); + pending.client.stop(); sharedClientState = null; sharedClientPromise = null; } @@ -283,6 +472,7 @@ export async function resolveSharedMatrixClient( state: created, timeoutMs: params.timeoutMs, initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, }); } return created.client; @@ -291,48 +481,18 @@ export async function resolveSharedMatrixClient( } } -export async function waitForMatrixSync(params: { +export async function waitForMatrixSync(_params: { client: MatrixClient; timeoutMs?: number; abortSignal?: AbortSignal; }): Promise { - const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000); - if (params.client.getSyncState() === SyncState.Syncing) return; - await new Promise((resolve, reject) => { - let done = false; - let timer: NodeJS.Timeout | undefined; - const cleanup = () => { - if (done) return; - done = true; - params.client.removeListener(ClientEvent.Sync, onSync); - if (params.abortSignal) { - params.abortSignal.removeEventListener("abort", onAbort); - } - if (timer) { - clearTimeout(timer); - timer = undefined; - } - }; - const onSync = (state: SyncState) => { - if (done) return; - if (state === SyncState.Prepared || state === SyncState.Syncing) { - cleanup(); - resolve(); - } - if (state === SyncState.Error) { - cleanup(); - reject(new Error("Matrix sync failed")); - } - }; - const onAbort = () => { - cleanup(); - reject(new Error("Matrix sync aborted")); - }; - params.client.on(ClientEvent.Sync, onSync); - params.abortSignal?.addEventListener("abort", onAbort, { once: true }); - timer = setTimeout(() => { - cleanup(); - reject(new Error("Matrix sync timed out")); - }, timeoutMs); - }); + // matrix-bot-sdk handles sync internally in start() + // 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/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index 4784a6f9f..45388462d 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -8,6 +8,7 @@ export type MatrixStoredCredentials = { homeserver: string; userId: string; accessToken: string; + deviceId?: string; createdAt: string; lastUsedAt?: string; }; @@ -94,5 +95,9 @@ 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/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index fdcf66fe4..df2f58706 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; -const MATRIX_SDK_PACKAGE = "matrix-js-sdk"; +const MATRIX_SDK_PACKAGE = "matrix-bot-sdk"; export function isMatrixSdkAvailable(): boolean { try { @@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: { if (isMatrixSdkAvailable()) return; const confirm = params.confirm; if (confirm) { - const ok = await confirm("Matrix requires matrix-js-sdk. Install now?"); + const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?"); if (!ok) { - throw new Error("Matrix requires matrix-js-sdk (install dependencies first)."); + throw new Error("Matrix requires matrix-bot-sdk (install dependencies first)."); } } @@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: { ); } if (!isMatrixSdkAvailable()) { - throw new Error("Matrix dependency install completed but matrix-js-sdk is still missing."); + throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing."); } } diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts index 8729ebc6e..7cd75d8a1 100644 --- a/extensions/matrix/src/matrix/index.ts +++ b/extensions/matrix/src/matrix/index.ts @@ -3,6 +3,7 @@ export { probeMatrix } from "./probe.js"; export { reactMatrixMessage, resolveMatrixRoomId, + sendReadReceiptMatrix, sendMessageMatrix, sendPollMatrix, sendTypingMatrix, diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index da3d43ff8..7b26677e0 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,5 +1,5 @@ -import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk"; -import { RoomMemberEvent } from "matrix-js-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; +import { AutojoinRoomsMixin } from "matrix-bot-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import type { CoreConfig } from "../../types.js"; @@ -19,25 +19,40 @@ export function registerMatrixAutoJoin(params: { const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; - client.on(RoomMemberEvent.Membership, async (_event: MatrixEvent, member: RoomMember) => { - if (member.userId !== client.getUserId()) return; - if (member.membership !== "invite") return; - const roomId = member.roomId; - if (autoJoin === "off") return; - if (autoJoin === "allowlist") { - const invitedRoom = client.getRoom(roomId); - const alias = invitedRoom?.getCanonicalAlias?.() ?? ""; - const altAliases = invitedRoom?.getAltAliases?.() ?? []; - const allowed = - autoJoinAllowlist.includes("*") || - autoJoinAllowlist.includes(roomId) || - (alias ? autoJoinAllowlist.includes(alias) : false) || - altAliases.some((value) => autoJoinAllowlist.includes(value)); - if (!allowed) { - logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); - return; - } + if (autoJoin === "off") { + return; + } + + if (autoJoin === "always") { + // Use the built-in autojoin mixin for "always" mode + AutojoinRoomsMixin.setupOnClient(client); + logVerbose("matrix: auto-join enabled for all invites"); + return; + } + + // For "allowlist" mode, handle invites manually + client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { + if (autoJoin !== "allowlist") return; + + // Get room alias if available + let alias: string | undefined; + try { + const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null); + alias = aliasState?.alias; + } catch { + // Ignore errors } + + const allowed = + autoJoinAllowlist.includes("*") || + autoJoinAllowlist.includes(roomId) || + (alias ? autoJoinAllowlist.includes(alias) : false); + + if (!allowed) { + logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); + return; + } + try { await client.joinRoom(roomId); logVerbose(`matrix: joined room ${roomId}`); diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index 9f64384f8..fff8383ca 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -1,80 +1,105 @@ -import type { - AccountDataEvents, - MatrixClient, - MatrixEvent, - Room, - RoomMember, -} from "matrix-js-sdk"; -import { ClientEvent, EventType } from "matrix-js-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; -function hasDirectFlag(member?: RoomMember | null): boolean { - if (!member?.events.member) return false; - const content = member.events.member.getContent() as { is_direct?: boolean } | undefined; - if (content?.is_direct === true) return true; - const prev = member.events.member.getPrevContent() as { is_direct?: boolean } | undefined; - return prev?.is_direct === true; -} +type DirectMessageCheck = { + roomId: string; + senderId?: string; + selfUserId?: string; +}; -export function isLikelyDirectRoom(params: { - room: Room; - senderId: string; - selfId?: string | null; -}): boolean { - if (!params.selfId) return false; - const memberCount = params.room.getJoinedMemberCount?.(); - if (typeof memberCount !== "number" || memberCount !== 2) return false; - return true; -} +type DirectRoomTrackerOptions = { + log?: (message: string) => void; +}; -export function isDirectRoomByFlag(params: { - room: Room; - senderId: string; - selfId?: string | null; -}): boolean { - if (!params.selfId) return false; - const selfMember = params.room.getMember(params.selfId); - const senderMember = params.room.getMember(params.senderId); - if (hasDirectFlag(selfMember) || hasDirectFlag(senderMember)) return true; - const inviter = selfMember?.getDMInviter() ?? senderMember?.getDMInviter(); - return Boolean(inviter); -} +const DM_CACHE_TTL_MS = 30_000; -type MatrixDirectAccountData = AccountDataEvents[EventType.Direct]; +export function createDirectRoomTracker( + client: MatrixClient, + opts: DirectRoomTrackerOptions = {}, +) { + const log = opts.log ?? (() => {}); + let lastDmUpdateMs = 0; + let cachedSelfUserId: string | null = null; + const memberCountCache = new Map(); -export function createDirectRoomTracker(client: MatrixClient) { - const directMap = new Map>(); + const ensureSelfUserId = async (): Promise => { + if (cachedSelfUserId) return cachedSelfUserId; + try { + cachedSelfUserId = await client.getUserId(); + } catch { + cachedSelfUserId = null; + } + return cachedSelfUserId; + }; - const updateDirectMap = (content: MatrixDirectAccountData) => { - directMap.clear(); - for (const [userId, rooms] of Object.entries(content)) { - if (!Array.isArray(rooms)) continue; - const ids = rooms.map((roomId) => String(roomId).trim()).filter(Boolean); - if (ids.length === 0) continue; - directMap.set(userId, new Set(ids)); + 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 initialDirect = client.getAccountData(EventType.Direct); - if (initialDirect) { - updateDirectMap(initialDirect.getContent() ?? {}); - } + 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; + } + }; - client.on(ClientEvent.AccountData, (event: MatrixEvent) => { - if (event.getType() !== EventType.Direct) return; - updateDirectMap(event.getContent() ?? {}); - }); + 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: (room: Room, senderId: string) => { - const roomId = room.roomId; - const directRooms = directMap.get(senderId); - const selfId = client.getUserId(); - const isDirectByFlag = isDirectRoomByFlag({ room, senderId, selfId }); - return ( - Boolean(directRooms?.has(roomId)) || - isDirectByFlag || - isLikelyDirectRoom({ room, senderId, selfId }) + 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/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 4b8ee51fd..2b4db0618 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,11 +1,17 @@ -import type { MatrixEvent, Room } from "matrix-js-sdk"; -import { EventType, RelationType, RoomEvent } from "matrix-js-sdk"; -import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; +import type { + LocationMessageEventContent, + MatrixClient, + MessageEventContent, +} from "matrix-bot-sdk"; +import { format } from "node:util"; import { formatAllowlistMatchMeta, + formatLocationText, mergeAllowlist, summarizeMapping, + toLocationContext, + type NormalizedLocation, type ReplyPayload, type RuntimeEnv, } from "clawdbot/plugin-sdk"; @@ -15,6 +21,7 @@ import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient, + stopSharedClient, } from "../client.js"; import { formatPollAsText, @@ -22,7 +29,12 @@ import { type PollStartContent, parsePollStartContent, } from "../poll-types.js"; -import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js"; +import { + reactMatrixMessage, + sendMessageMatrix, + sendReadReceiptMatrix, + sendTypingMatrix, +} from "../send.js"; import { resolveMatrixAllowListMatch, resolveMatrixAllowListMatches, @@ -38,6 +50,118 @@ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads. import { resolveMatrixTargets } from "../../resolve-targets.js"; import { getMatrixRuntime } from "../../runtime.js"; +// Constants that were previously from matrix-js-sdk +const EventType = { + RoomMessage: "m.room.message", + RoomMessageEncrypted: "m.room.encrypted", + RoomMember: "m.room.member", + Location: "m.location", +} as const; + +const RelationType = { + Replace: "m.replace", +} as const; + +// Type for raw Matrix events from matrix-bot-sdk +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; +}; + +type RoomMessageEventContent = MessageEventContent & { + url?: string; + info?: { + mimetype?: string; + }; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +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, + }; +} + +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), + }; +} + export type MonitorMatrixOpts = { runtime?: RuntimeEnv; abortSignal?: AbortSignal; @@ -56,13 +180,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi 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: console.log, - error: console.error, + 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(); @@ -70,8 +204,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim(); const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; - let roomsConfig = cfg.channels?.matrix?.rooms; + let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms; if (allowFrom.length > 0) { const entries = allowFrom @@ -163,7 +298,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi ...cfg.channels?.matrix?.dm, allowFrom, }, - rooms: roomsConfig, + ...(roomsConfig ? { groups: roomsConfig } : {}), }, }, }; @@ -185,13 +320,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client); const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); - const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); - const logVerboseMessage = (message: string) => { - if (core.logging.shouldLogVerbose()) { - logger.debug(message); - } - }; - const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; @@ -206,30 +334,75 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const startupMs = Date.now(); const startupGraceMs = 0; - const directTracker = createDirectRoomTracker(client); + const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); registerMatrixAutoJoin({ client, cfg, runtime }); + const warnedEncryptedRooms = new Set(); + const warnedCryptoMissingRooms = new Set(); - const handleTimeline = async ( - event: MatrixEvent, - room: Room | undefined, - toStartOfTimeline?: boolean, + const roomInfoCache = new Map< + string, + { name?: string; canonicalAlias?: string; altAliases: string[] } + >(); + + // Helper to get room info + const getRoomInfo = async (roomId: string) => { + 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; + }; + + // Helper to get member display name + 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; + } + }; + + const handleRoomMessage = async ( + roomId: string, + event: MatrixRawEvent, ) => { try { - if (!room) return; - if (toStartOfTimeline) return; - if (event.getType() === EventType.RoomMessageEncrypted || event.isDecryptionFailure()) { + const eventType = event.type; + if (eventType === EventType.RoomMessageEncrypted) { + // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled return; } - const eventType = event.getType(); const isPollEvent = isPollStartType(eventType); - if (eventType !== EventType.RoomMessage && !isPollEvent) return; - if (event.isRedacted()) return; - const senderId = event.getSender(); + 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; - if (senderId === client.getUserId()) return; - const eventTs = event.getTs(); - const eventAge = event.getAge(); + 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; } @@ -241,15 +414,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return; } - let content = event.getContent(); + 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.getContent(); + const pollStartContent = event.content as PollStartContent; const pollSummary = parsePollStartContent(pollStartContent); if (pollSummary) { - pollSummary.eventId = event.getId() ?? ""; - pollSummary.roomId = room.roomId; + pollSummary.eventId = event.event_id ?? ""; + pollSummary.roomId = roomId; pollSummary.sender = senderId; - pollSummary.senderName = room.getMember(senderId)?.name ?? senderId; + const senderDisplayName = await getMemberDisplayName(roomId, senderId); + pollSummary.senderName = senderDisplayName; const pollText = formatPollAsText(pollSummary); content = { msgtype: "m.text", @@ -260,50 +441,64 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } } + const locationPayload = 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 roomId = room.roomId; - const isDirectMessage = directTracker.isDirectMessage(room, senderId); + const isDirectMessage = await directTracker.isDirectMessage({ + roomId, + senderId, + selfUserId, + }); const isRoom = !isDirectMessage; - if (!isDirectMessage && groupPolicy === "disabled") return; + if (isRoom && groupPolicy === "disabled") return; - const roomAliases = [ - room.getCanonicalAlias?.() ?? "", - ...(room.getAltAliases?.() ?? []), - ].filter(Boolean); - const roomName = room.name ?? undefined; - const roomConfigInfo = resolveMatrixRoomConfig({ - rooms: cfg.channels?.matrix?.rooms, - roomId, - aliases: roomAliases, - name: roomName, - }); - const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ - roomConfigInfo.matchSource ?? "none" - }`; + 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 (roomConfigInfo.config && !roomConfigInfo.allowed) { + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); return; } - if (groupPolicy === "allowlist") { - if (!roomConfigInfo.allowlistConfigured) { + if (isRoom && groupPolicy === "allowlist") { + if (!roomConfigInfo?.allowlistConfigured) { logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); return; } - if (!roomConfigInfo.config) { + if (!roomConfig) { logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); return; } } - const senderName = room.getMember(senderId)?.name ?? senderId; + const senderName = await getMemberDisplayName(roomId, senderId); const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); + const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; + const effectiveGroupAllowFrom = normalizeAllowListLower([ + ...groupAllowFrom, + ...storeAllowFrom, + ]); + const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") return; @@ -353,9 +548,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } } - if (isRoom && roomConfigInfo.config?.users?.length) { + const roomUsers = roomConfig?.users ?? []; + if (isRoom && roomUsers.length > 0) { const userMatch = resolveMatrixAllowListMatch({ - allowList: normalizeAllowListLower(roomConfigInfo.config.users), + allowList: normalizeAllowListLower(roomUsers), userId: senderId, userName: senderName, }); @@ -368,11 +564,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return; } } + if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + const groupAllowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }); + if (!groupAllowMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + groupAllowMatch, + )})`, + ); + return; + } + } if (isRoom) { logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); } - const rawBody = content.body.trim(); + const rawBody = locationPayload?.text + ?? (typeof content.body === "string" ? content.body.trim() : ""); let media: { path: string; contentType?: string; @@ -406,7 +618,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const { wasMentioned, hasExplicitMention } = resolveMentions({ content, - userId: client.getUserId(), + userId: selfUserId, text: bodyText, mentionRegexes, }); @@ -420,10 +632,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi userId: senderId, userName: senderName, }); + const senderAllowedForGroup = groupAllowConfigured + ? resolveMatrixAllowListMatches({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }) + : false; + const senderAllowedForRoomUsers = + isRoom && roomUsers.length > 0 + ? resolveMatrixAllowListMatches({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }) + : false; const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers: [ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, + { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, ], }); if ( @@ -436,12 +665,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return; } const shouldRequireMention = isRoom - ? roomConfigInfo.config?.autoReply === true + ? roomConfig?.autoReply === true ? false - : roomConfigInfo.config?.autoReply === false + : roomConfig?.autoReply === false ? true - : typeof roomConfigInfo.config?.requireMention === "boolean" - ? roomConfigInfo.config.requireMention + : typeof roomConfig?.requireMention === "boolean" + ? roomConfig?.requireMention : true : false; const shouldBypassMention = @@ -457,13 +686,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return; } - const messageId = event.getId() ?? ""; + 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: event.isThreadRoot, + isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available }); const route = core.channel.routing.resolveAgentRoute({ @@ -484,16 +714,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi storePath, sessionKey: route.sessionKey, }); - const body = core.channel.reply.formatAgentEnvelope({ - channel: "Matrix", - from: envelopeFrom, - timestamp: event.getTs() ?? undefined, - previousTimestamp, - envelope: envelopeOptions, - body: textWithId, - }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Matrix", + from: envelopeFrom, + timestamp: eventTs ?? undefined, + previousTimestamp, + envelope: envelopeOptions, + body: textWithId, + }); - const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: body, RawBody: bodyText, @@ -508,18 +738,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi SenderId: senderId, SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), GroupSubject: isRoom ? (roomName ?? roomId) : undefined, - GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? 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 : (event.replyEventId ?? undefined), + ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), MessageThreadId: threadTarget, - Timestamp: event.getTs() ?? undefined, + Timestamp: eventTs ?? undefined, MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, + ...(locationPayload?.context ?? {}), CommandAuthorized: commandAuthorized, CommandSource: "text" as const, OriginatingChannel: "matrix" as const, @@ -577,6 +808,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi return; } + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + let didSendReply = false; const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -606,7 +845,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi dispatcher, replyOptions: { ...replyOptions, - skillFilter: roomConfigInfo.config?.skills, + skillFilter: roomConfig?.skills, }, }); markDispatchIdle(); @@ -628,15 +867,100 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi } }; - client.on(RoomEvent.Timeline, handleTimeline); + // matrix-bot-sdk uses on("room.message", handler) + client.on("room.message", handleRoomMessage); - await resolveSharedMatrixClient({ cfg, auth: authWithLimit, startClient: true }); - runtime.log?.(`matrix: logged in as ${auth.userId}`); + 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}`); + }); + + // Handle failed E2EE decryption + 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 warning = + "matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart"; + 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"}`, + ); + } + }); + + logVerboseMessage("matrix: starting client"); + await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + }); + logVerboseMessage("matrix: client started"); + + // matrix-bot-sdk 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.requestOwnUserVerification(); + if (verificationRequest) { + logger.info("matrix: device verification requested - please verify in another client"); + } + } catch (err) { + logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)"); + } + } await new Promise((resolve) => { const onAbort = () => { try { - client.stopClient(); + logVerboseMessage("matrix: stopping client"); + stopSharedClient(); } finally { setActiveMatrixClient(null); resolve(); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index 4a2405937..dc49e7c45 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -1,35 +1,68 @@ -import type { MatrixClient } from "matrix-js-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.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> { - const url = params.client.mxcUrlToHttp( - params.mxcUrl, - undefined, - undefined, - undefined, - false, - true, - true, - ); + // matrix-bot-sdk provides mxcToHttp helper + const url = params.client.mxcToHttp(params.mxcUrl); if (!url) return null; - const token = params.client.getAccessToken(); - const res = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }); - if (!res.ok) { - throw new Error(`Matrix media download failed: HTTP ${res.status}`); + + // Use the client's download method which handles auth + try { + const buffer = await params.client.downloadContent(params.mxcUrl); + if (buffer.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + return { buffer: Buffer.from(buffer) }; + } catch (err) { + throw new Error(`Matrix media download failed: ${String(err)}`); } - const buffer = Buffer.from(await res.arrayBuffer()); - if (buffer.byteLength > params.maxBytes) { +} + +/** + * Download and decrypt encrypted media from a Matrix room. + */ +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"); + } + + // Download the encrypted content + const encryptedBuffer = await params.client.downloadContent(params.file.url); + if (encryptedBuffer.byteLength > params.maxBytes) { throw new Error("Matrix media exceeds configured size limit"); } - const headerType = res.headers.get("content-type") ?? undefined; - return { buffer, headerType }; + + // Decrypt using matrix-bot-sdk crypto + const decrypted = await params.client.crypto.decryptMedia( + Buffer.from(encryptedBuffer), + params.file, + ); + + return { buffer: decrypted }; } export async function downloadMatrixMedia(params: { @@ -37,16 +70,30 @@ export async function downloadMatrixMedia(params: { mxcUrl: string; contentType?: string; maxBytes: number; + file?: EncryptedFile; }): Promise<{ path: string; contentType?: string; placeholder: string; } | null> { - const fetched = await fetchMatrixMediaBuffer({ - client: params.client, - mxcUrl: params.mxcUrl, - maxBytes: params.maxBytes, - }); + let fetched: { buffer: Buffer; headerType?: string } | null; + + 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( diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts index 3a10fdda1..1053b3fa1 100644 --- a/extensions/matrix/src/matrix/monitor/mentions.ts +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -1,16 +1,22 @@ -import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; - import { getMatrixRuntime } from "../../runtime.js"; +// Type for room message content with mentions +type MessageContentWithMentions = { + msgtype: string; + body: string; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; +}; + export function resolveMentions(params: { - content: RoomMessageEventContent; + content: MessageContentWithMentions; userId?: string | null; text?: string; mentionRegexes: RegExp[]; }) { - const mentions = params.content["m.mentions"] as - | { user_ids?: string[]; room?: boolean } - | undefined; + const mentions = params.content["m.mentions"]; const mentionedUsers = Array.isArray(mentions?.user_ids) ? new Set(mentions.user_ids) : new Set(); diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 26233de08..7a9cc06aa 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-js-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts index fd9df6fad..f45f54cf4 100644 --- a/extensions/matrix/src/matrix/monitor/rooms.ts +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -1,4 +1,4 @@ -import type { MatrixConfig, MatrixRoomConfig } from "../../types.js"; +import type { MatrixRoomConfig } from "../../types.js"; import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "clawdbot/plugin-sdk"; export type MatrixRoomConfigResolved = { @@ -10,7 +10,7 @@ export type MatrixRoomConfigResolved = { }; export function resolveMatrixRoomConfig(params: { - rooms?: MatrixConfig["rooms"]; + rooms?: Record; roomId: string; aliases: string[]; name?: string | null; diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts index 50a42ac67..3378d3b2b 100644 --- a/extensions/matrix/src/matrix/monitor/threads.ts +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -1,6 +1,25 @@ -import type { MatrixEvent } from "matrix-js-sdk"; -import { RelationType } from "matrix-js-sdk"; -import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js"; +// Type for raw Matrix event from matrix-bot-sdk +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; +}; + +type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +const RelationType = { + Thread: "m.thread", +} as const; export function resolveMatrixThreadTarget(params: { threadReplies: "off" | "inbound" | "always"; @@ -22,13 +41,9 @@ export function resolveMatrixThreadTarget(params: { } export function resolveMatrixThreadRootId(params: { - event: MatrixEvent; + event: MatrixRawEvent; content: RoomMessageEventContent; }): string | undefined { - const fromThread = params.event.getThread?.()?.id; - if (fromThread) return fromThread; - const direct = params.event.threadRootId ?? undefined; - if (direct) return direct; const relates = params.content["m.relates_to"]; if (!relates || typeof relates !== "object") return undefined; if ("rel_type" in relates && relates.rel_type === RelationType.Thread) { diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts index 38a465cfb..28b36d42d 100644 --- a/extensions/matrix/src/matrix/poll-types.ts +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -7,8 +7,6 @@ * - m.poll.end - Closes a poll */ -import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js"; -import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js"; import type { PollInput } from "clawdbot/plugin-sdk"; export const M_POLL_START = "m.poll.start" as const; @@ -34,7 +32,9 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END]; export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; -export type TextContent = ExtensibleAnyMessageEventContent & { +export type TextContent = { + "m.text"?: string; + "org.matrix.msc1767.text"?: string; body?: string; }; @@ -53,7 +53,13 @@ export type LegacyPollStartContent = { "m.poll"?: PollStartSubtype; }; -export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent; +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; diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index baf3c502c..3bfdd1728 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -49,9 +49,10 @@ export async function probeMatrix(params: { accessToken: params.accessToken, localTimeoutMs: params.timeoutMs, }); - const res = await client.whoami(); + // matrix-bot-sdk uses getUserId() which calls whoami internally + const userId = await client.getUserId(); result.ok = true; - result.userId = res.user_id ?? null; + result.userId = userId ?? null; result.elapsedMs = Date.now() - started; return result; @@ -59,8 +60,8 @@ export async function probeMatrix(params: { return { ...result, status: - typeof err === "object" && err && "httpStatus" in err - ? Number((err as { httpStatus?: number }).httpStatus) + 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/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 1cae12f32..234b84d07 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -3,22 +3,20 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; import { setMatrixRuntime } from "../runtime.js"; -vi.mock("matrix-js-sdk", () => ({ - EventType: { - Direct: "m.direct", - RoomMessage: "m.room.message", - Reaction: "m.reaction", +vi.mock("matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); }, - MsgType: { - Text: "m.text", - File: "m.file", - Image: "m.image", - Audio: "m.audio", - Video: "m.video", - }, - RelationType: { - Annotation: "m.annotation", + LogService: { + setLogger: vi.fn(), }, + MatrixClient: vi.fn(), + SimpleFsStorageProvider: vi.fn(), + RustSdkCryptoStorageProvider: vi.fn(), })); const loadWebMediaMock = vi.fn().mockResolvedValue({ @@ -52,14 +50,13 @@ const runtimeStub = { let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; const makeClient = () => { - const sendMessage = vi.fn().mockResolvedValue({ event_id: "evt1" }); - const uploadContent = vi.fn().mockResolvedValue({ - content_uri: "mxc://example/file", - }); + const sendMessage = vi.fn().mockResolvedValue("evt1"); + const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); const client = { sendMessage, uploadContent, - } as unknown as import("matrix-js-sdk").MatrixClient; + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + } as unknown as import("matrix-bot-sdk").MatrixClient; return { client, sendMessage, uploadContent }; }; @@ -96,4 +93,41 @@ describe("sendMessageMatrix media", () => { 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"); + }); }); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 9e4499594..dce462909 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,9 +1,14 @@ -import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk"; -import { EventType, MsgType, RelationType } from "matrix-js-sdk"; import type { - RoomMessageEventContent, - ReactionEventContent, -} from "matrix-js-sdk/lib/@types/events.js"; + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MessageEventContent, + TextualMessageEventContent, + TimedFileInfo, + VideoFileInfo, + MatrixClient, +} from "matrix-bot-sdk"; +import { parseBuffer, type IFileInfo } from "music-metadata"; import type { PollInput } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; @@ -13,7 +18,6 @@ import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient, - waitForMatrixSync, } from "./client.js"; import { markdownToMatrixHtml } from "./format.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; @@ -22,18 +26,63 @@ import type { CoreConfig } from "../types.js"; const MATRIX_TEXT_LIMIT = 4000; const getCore = () => getMatrixRuntime(); -type MatrixDirectAccountData = AccountDataEvents[EventType.Direct]; +// Message types +const MsgType = { + Text: "m.text", + Image: "m.image", + Audio: "m.audio", + Video: "m.video", + File: "m.file", + Notice: "m.notice", +} as const; + +// Relation types +const RelationType = { + Annotation: "m.annotation", + Replace: "m.replace", + Thread: "m.thread", +} as const; + +// Event types +const EventType = { + Direct: "m.direct", + Reaction: "m.reaction", + RoomMessage: "m.room.message", +} as const; + +type MatrixDirectAccountData = Record; type MatrixReplyRelation = { "m.in_reply_to": { event_id: string }; }; -type MatrixMessageContent = Record & { - msgtype: MsgType; - body: string; +type MatrixReplyMeta = { + "m.relates_to"?: MatrixReplyRelation; }; -type MatrixUploadContent = Parameters[0]; +type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo; + +type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta; + +type MatrixMediaContent = MessageEventContent & + MatrixReplyMeta & { + info?: MatrixMediaInfo; + url?: string; + file?: EncryptedFile; + filename?: string; + "org.matrix.msc3245.voice"?: Record; + "org.matrix.msc1767.audio"?: { duration: number }; + }; + +type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; + +type ReactionEventContent = { + "m.relates_to": { + rel_type: typeof RelationType.Annotation; + event_id: string; + key: string; + }; +}; export type MatrixSendResult = { messageId: string; @@ -83,13 +132,14 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis if (!trimmed.startsWith("@")) { throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); } - const directEvent = client.getAccountData(EventType.Direct); - const directContent = directEvent?.getContent(); - const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; - if (list.length > 0) return list[0]; - const server = await client.getAccountDataFromServer(EventType.Direct); - const serverList = Array.isArray(server?.[trimmed]) ? server[trimmed] : []; - if (serverList.length > 0) return serverList[0]; + // matrix-bot-sdk: use getAccountData to retrieve m.direct + try { + const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null; + const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; + if (list.length > 0) return list[0]; + } catch { + // Ignore errors, try fetching from server + } throw new Error( `No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`, ); @@ -117,75 +167,116 @@ export async function resolveMatrixRoomId( return await resolveDirectRoomId(client, target); } if (target.startsWith("#")) { - const resolved = await client.getRoomIdForAlias(target); - if (!resolved?.room_id) { + const resolved = await client.resolveRoom(target); + if (!resolved) { throw new Error(`Matrix alias ${target} could not be resolved`); } - return resolved.room_id; + return resolved; } return target; } -type MatrixImageInfo = { - w?: number; - h?: number; - thumbnail_url?: string; - thumbnail_info?: { - w: number; - h: number; - mimetype: string; - size: number; - }; +type MatrixMediaMsgType = + | typeof MsgType.Image + | typeof MsgType.Audio + | typeof MsgType.Video + | typeof MsgType.File; + +type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +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; +} + +type MatrixFormattedContent = MessageEventContent & { + format?: string; + formatted_body?: string; }; function buildMediaContent(params: { - msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File; + msgtype: MatrixMediaMsgType; body: string; - url: string; + url?: string; filename?: string; mimetype?: string; size: number; relation?: MatrixReplyRelation; isVoice?: boolean; durationMs?: number; - imageInfo?: MatrixImageInfo; -}): RoomMessageEventContent { - const info: Record = { mimetype: params.mimetype, size: params.size }; - if (params.durationMs !== undefined) { - info.duration = params.durationMs; - } - if (params.imageInfo) { - if (params.imageInfo.w) info.w = params.imageInfo.w; - if (params.imageInfo.h) info.h = params.imageInfo.h; - if (params.imageInfo.thumbnail_url) { - info.thumbnail_url = params.imageInfo.thumbnail_url; - if (params.imageInfo.thumbnail_info) { - info.thumbnail_info = params.imageInfo.thumbnail_info; - } - } - } - const base: MatrixMessageContent = { + imageInfo?: DimensionalFileInfo; + file?: EncryptedFile; // For encrypted media +}): 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, - url: params.url, + 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"] = {}; - base["org.matrix.msc1767.audio"] = { - duration: params.durationMs, - }; + 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 as RoomMessageEventContent; + return base; } -function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMessageEventContent { - const content: MatrixMessageContent = relation +function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent { + const content: MatrixTextContent = relation ? { msgtype: MsgType.Text, body, @@ -196,10 +287,10 @@ function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMes body, }; applyMatrixFormatting(content, body); - return content as RoomMessageEventContent; + return content; } -function applyMatrixFormatting(content: MatrixMessageContent, body: string): void { +function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { const formatted = markdownToMatrixHtml(body ?? ""); if (!formatted) return; content.format = "org.matrix.custom.html"; @@ -215,7 +306,7 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined function resolveMatrixMsgType( contentType?: string, fileName?: string, -): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File { +): MatrixMediaMsgType { const kind = getCore().media.mediaKindFromMime(contentType ?? ""); switch (kind) { case "image": @@ -247,10 +338,10 @@ const THUMBNAIL_QUALITY = 80; async function prepareImageInfo(params: { buffer: Buffer; client: MatrixClient; -}): Promise { +}): Promise { const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null); if (!meta) return undefined; - const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height }; + const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; const maxDim = Math.max(meta.width, meta.height); if (maxDim > THUMBNAIL_MAX_SIDE) { try { @@ -261,11 +352,12 @@ async function prepareImageInfo(params: { withoutEnlargement: true, }); const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null); - const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, { - type: "image/jpeg", - name: "thumbnail.jpg", - }); - imageInfo.thumbnail_url = thumbUri.content_uri; + const thumbUri = await params.client.uploadContent( + thumbBuffer, + "image/jpeg", + "thumbnail.jpg", + ); + imageInfo.thumbnail_url = thumbUri; if (thumbMeta) { imageInfo.thumbnail_info = { w: thumbMeta.width, @@ -281,21 +373,76 @@ async function prepareImageInfo(params: { return imageInfo; } +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: MatrixUploadContent | Buffer, + file: Buffer, params: { contentType?: string; filename?: string; - includeFilename?: boolean; }, ): Promise { - const upload = await client.uploadContent(file as MatrixUploadContent, { - type: params.contentType, - name: params.filename, - includeFilename: params.includeFilename, - }); - return upload.content_uri; + return await client.uploadContent(file, params.contentType, params.filename); +} + +/** + * Upload media with optional encryption for E2EE rooms. + */ +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 }; } async function resolveMatrixClient(opts: { @@ -318,14 +465,11 @@ async function resolveMatrixClient(opts: { homeserver: auth.homeserver, userId: auth.userId, accessToken: auth.accessToken, + encryption: auth.encryption, localTimeoutMs: opts.timeoutMs, }); - await client.startClient({ - initialSyncLimit: 0, - lazyLoadMembers: true, - threadSupport: true, - }); - await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs }); + // matrix-bot-sdk uses start() instead of startClient() + await client.start(); return { client, stopOnDone: true }; } @@ -350,17 +494,26 @@ export async function sendMessageMatrix( const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? undefined : buildReplyRelation(opts.replyToId); - const sendContent = (content: RoomMessageEventContent) => - threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content); + const sendContent = async (content: MatrixOutboundContent) => { + // matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; let lastMessageId = ""; if (opts.mediaUrl) { const maxBytes = resolveMediaMaxBytes(); const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); - const contentUri = await uploadFile(client, media.buffer, { + 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, @@ -375,31 +528,33 @@ export async function sendMessageMatrix( const content = buildMediaContent({ msgtype, body, - url: contentUri, + url: uploaded.url, + file: uploaded.file, filename: media.fileName, mimetype: media.contentType, size: media.buffer.byteLength, + durationMs, relation, isVoice: useVoice, imageInfo, }); - const response = await sendContent(content); - lastMessageId = response.event_id ?? lastMessageId; + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; const textChunks = useVoice ? chunks : rest; for (const chunk of textChunks) { const text = chunk.trim(); if (!text) continue; const followup = buildTextContent(text); - const followupRes = await sendContent(followup); - lastMessageId = followupRes.event_id ?? lastMessageId; + 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 response = await sendContent(content); - lastMessageId = response.event_id ?? lastMessageId; + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; } } @@ -409,7 +564,7 @@ export async function sendMessageMatrix( }; } finally { if (stopOnDone) { - client.stopClient(); + client.stop(); } } } @@ -433,27 +588,16 @@ export async function sendPollMatrix( try { const roomId = await resolveMatrixRoomId(client, to); const pollContent = buildPollStartContent(poll); - const threadId = normalizeThreadId(opts.threadId); - const response = threadId - ? await client.sendEvent( - roomId, - threadId, - M_POLL_START, - pollContent, - ) - : await client.sendEvent( - roomId, - M_POLL_START, - pollContent, - ); + // matrix-bot-sdk sendEvent returns eventId string directly + const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent); return { - eventId: response.event_id ?? "unknown", + eventId: eventId ?? "unknown", roomId, }; } finally { if (stopOnDone) { - client.stopClient(); + client.stop(); } } } @@ -470,10 +614,29 @@ export async function sendTypingMatrix( }); try { const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; - await resolved.sendTyping(roomId, typing, resolvedTimeoutMs); + await resolved.setTyping(roomId, typing, resolvedTimeoutMs); } finally { if (stopOnDone) { - resolved.stopClient(); + 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(); } } } @@ -502,7 +665,7 @@ export async function reactMatrixMessage( await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); } finally { if (stopOnDone) { - resolved.stopClient(); + resolved.stop(); } } } diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 5dba54238..28f24b788 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -35,8 +35,9 @@ function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { await prompter.note( [ - "Matrix requires a homeserver URL + user ID.", - "Use an access token or a password (password logs in and stores a token).", + "Matrix requires a homeserver URL.", + "Use an access token (recommended) or a password (logs in and stores a token).", + "With access token: user ID is fetched automatically.", "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, ].join("\n"), @@ -146,8 +147,8 @@ function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" }; } -function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) { - const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); +function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { + const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); return { ...cfg, channels: { @@ -155,7 +156,7 @@ function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) { matrix: { ...cfg.channels?.matrix, enabled: true, - rooms, + groups, }, }, }; @@ -180,9 +181,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { return { channel, configured, - statusLines: [`Matrix: ${configured ? "configured" : "needs homeserver + user id"}`], + statusLines: [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ], selectionHint: !sdkReady - ? "install matrix-js-sdk" + ? "install matrix-bot-sdk" : configured ? "configured" : "needs auth", @@ -208,7 +211,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { 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 && envUserId && (envAccessToken || envPassword)); + const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); if ( envReady && @@ -252,22 +255,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { }), ).trim(); - const 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(); - let accessToken = existing.accessToken ?? ""; let password = existing.password ?? ""; + let userId = existing.userId ?? ""; if (accessToken || password) { const keep = await prompter.confirm({ @@ -277,15 +267,17 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { if (!keep) { accessToken = ""; password = ""; + userId = ""; } } 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" }, - { value: "password", label: "Password (stores token)" }, + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, ], })) as "token" | "password"; @@ -296,7 +288,24 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); + // With access token, we can fetch the userId automatically - don't prompt for it + // The client.ts will use whoami() to get it + userId = ""; } else { + // Password auth requires user ID upfront + userId = String( + await prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + if (!raw.startsWith("@")) return "Matrix user IDs should start with @"; + if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)"; + return undefined; + }, + }), + ).trim(); password = String( await prompter.text({ message: "Matrix password", @@ -313,6 +322,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { }), ).trim(); + // Ask about E2EE encryption + const enableEncryption = await prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + next = { ...next, channels: { @@ -321,10 +336,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { ...next.channels?.matrix, enabled: true, homeserver, - userId, + userId: userId || undefined, accessToken: accessToken || undefined, password: password || undefined, deviceName: deviceName || undefined, + encryption: enableEncryption || undefined, }, }, }; @@ -333,13 +349,14 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { 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(next.channels?.matrix?.rooms ?? {}), + currentEntries: Object.keys(existingGroups ?? {}), placeholder: "!roomId:server, #alias:server, Project Room", - updatePrompt: Boolean(next.channels?.matrix?.rooms), + updatePrompt: Boolean(existingGroups), }); if (accessConfig) { if (accessConfig.policy !== "allowlist") { @@ -398,7 +415,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { } } next = setMatrixGroupPolicy(next, "allowlist"); - next = setMatrixRoomAllowlist(next, roomKeys); + next = setMatrixGroupRooms(next, roomKeys); } } diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index e5c91e2e2..8b2e96d34 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -51,12 +51,16 @@ export type MatrixConfig = { password?: string; /** Optional device name when logging in via password. */ deviceName?: string; - /** Initial sync limit for startup (default: matrix-js-sdk default). */ + /** Initial sync limit for startup (default: matrix-bot-sdk default). */ 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 (user IDs or localparts). */ + groupAllowFrom?: Array; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; /** How to handle thread replies (off|inbound|always). */ @@ -72,6 +76,8 @@ export type MatrixConfig = { /** Direct message policy + allowlist overrides. */ dm?: MatrixDmConfig; /** Room config allowlist keyed by room ID, alias, or name. */ + groups?: Record; + /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */ rooms?: Record; /** Per-action tool gating (default: true for all). */ actions?: MatrixActionConfig; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 0956f31d7..c184db128 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -104,6 +104,8 @@ export { resolveMentionGatingWithBypass, } from "../channels/mention-gating.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; +export type { NormalizedLocation } from "../channels/location.js"; +export { formatLocationText, toLocationContext } from "../channels/location.js"; export { resolveDiscordGroupRequireMention, resolveIMessageGroupRequireMention,