From 73957ca92b17ef623da2c2fd3dbb8682d2d406e3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 13:59:04 +0000 Subject: [PATCH] refactor: split matrix provider modules --- extensions/matrix/src/matrix/actions.ts | 533 +----------- .../matrix/src/matrix/actions/client.ts | 53 ++ .../matrix/src/matrix/actions/messages.ts | 120 +++ extensions/matrix/src/matrix/actions/pins.ts | 70 ++ .../matrix/src/matrix/actions/reactions.ts | 84 ++ extensions/matrix/src/matrix/actions/room.ts | 88 ++ .../matrix/src/matrix/actions/summary.ts | 77 ++ extensions/matrix/src/matrix/actions/types.ts | 84 ++ extensions/matrix/src/matrix/client.ts | 654 +-------------- extensions/matrix/src/matrix/client/config.ts | 165 ++++ .../matrix/src/matrix/client/create-client.ts | 127 +++ .../matrix/src/matrix/client/logging.ts | 35 + .../matrix/src/matrix/client/runtime.ts | 4 + extensions/matrix/src/matrix/client/shared.ts | 169 ++++ .../matrix/src/matrix/client/storage.ts | 131 +++ extensions/matrix/src/matrix/client/types.ts | 34 + .../matrix/src/matrix/monitor/events.ts | 103 +++ .../matrix/src/matrix/monitor/handler.ts | 604 ++++++++++++++ extensions/matrix/src/matrix/monitor/index.ts | 782 +----------------- .../matrix/src/matrix/monitor/location.ts | 83 ++ .../matrix/src/matrix/monitor/room-info.ts | 58 ++ extensions/matrix/src/matrix/monitor/types.ts | 38 + extensions/matrix/src/matrix/send.ts | 524 +----------- extensions/matrix/src/matrix/send/client.ts | 63 ++ .../matrix/src/matrix/send/formatting.ts | 92 +++ extensions/matrix/src/matrix/send/media.ts | 220 +++++ extensions/matrix/src/matrix/send/targets.ts | 66 ++ extensions/matrix/src/matrix/send/types.ts | 108 +++ 28 files changed, 2765 insertions(+), 2404 deletions(-) create mode 100644 extensions/matrix/src/matrix/actions/client.ts create mode 100644 extensions/matrix/src/matrix/actions/messages.ts create mode 100644 extensions/matrix/src/matrix/actions/pins.ts create mode 100644 extensions/matrix/src/matrix/actions/reactions.ts create mode 100644 extensions/matrix/src/matrix/actions/room.ts create mode 100644 extensions/matrix/src/matrix/actions/summary.ts create mode 100644 extensions/matrix/src/matrix/actions/types.ts create mode 100644 extensions/matrix/src/matrix/client/config.ts create mode 100644 extensions/matrix/src/matrix/client/create-client.ts create mode 100644 extensions/matrix/src/matrix/client/logging.ts create mode 100644 extensions/matrix/src/matrix/client/runtime.ts create mode 100644 extensions/matrix/src/matrix/client/shared.ts create mode 100644 extensions/matrix/src/matrix/client/storage.ts create mode 100644 extensions/matrix/src/matrix/client/types.ts create mode 100644 extensions/matrix/src/matrix/monitor/events.ts create mode 100644 extensions/matrix/src/matrix/monitor/handler.ts create mode 100644 extensions/matrix/src/matrix/monitor/location.ts create mode 100644 extensions/matrix/src/matrix/monitor/room-info.ts create mode 100644 extensions/matrix/src/matrix/monitor/types.ts create mode 100644 extensions/matrix/src/matrix/send/client.ts create mode 100644 extensions/matrix/src/matrix/send/formatting.ts create mode 100644 extensions/matrix/src/matrix/send/media.ts create mode 100644 extensions/matrix/src/matrix/send/targets.ts create mode 100644 extensions/matrix/src/matrix/send/types.ts diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts index 81c6b73302..34d24b6dd3 100644 --- a/extensions/matrix/src/matrix/actions.ts +++ b/extensions/matrix/src/matrix/actions.ts @@ -1,518 +1,15 @@ -import type { MatrixClient } from "matrix-bot-sdk"; - -import { getMatrixRuntime } from "../runtime.js"; -import type { CoreConfig } from "../types.js"; -import { getActiveMatrixClient } from "./active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "./client.js"; -import { - reactMatrixMessage, - resolveMatrixRoomId, - 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; -}; - -export type MatrixMessageSummary = { - eventId?: string; - sender?: string; - body?: string; - msgtype?: string; - timestamp?: number; - relatesTo?: { - relType?: string; - eventId?: string; - key?: string; - }; -}; - -export type MatrixReactionSummary = { - key: string; - count: number; - users: string[]; -}; - -type MatrixActionClient = { - client: MatrixClient; - stopOnDone: boolean; -}; - -function ensureNodeRuntime() { - if (isBunRuntime()) { - throw new Error("Matrix support requires Node (bun runtime not supported)"); - } -} - -async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise { - ensureNodeRuntime(); - if (opts.client) return { client: opts.client, stopOnDone: false }; - const active = getActiveMatrixClient(); - if (active) return { client: active, stopOnDone: false }; - const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT); - if (shouldShareClient) { - const client = await resolveSharedMatrixClient({ - cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, - timeoutMs: opts.timeoutMs, - }); - return { client, stopOnDone: false }; - } - const auth = await resolveMatrixAuth({ - cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, - }); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, - }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await client.crypto.prepare(joinedRooms); - } catch { - // Ignore crypto prep failures for one-off actions. - } - } - await client.start(); - return { client, stopOnDone: true }; -} - -function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary { - const content = event.content as RoomMessageEventContent; - const relates = content["m.relates_to"]; - let relType: string | undefined; - let eventId: string | undefined; - if (relates) { - if ("rel_type" in relates) { - relType = relates.rel_type; - eventId = relates.event_id; - } else if ("m.in_reply_to" in relates) { - eventId = relates["m.in_reply_to"]?.event_id; - } - } - const relatesTo = - relType || eventId - ? { - relType, - eventId, - } - : undefined; - return { - eventId: event.event_id, - sender: event.sender, - body: content.body, - msgtype: content.msgtype, - timestamp: event.origin_server_ts, - relatesTo, - }; -} - -async function readPinnedEvents(client: MatrixClient, roomId: string): Promise { - try { - const content = (await client.getRoomStateEvent( - roomId, - EventType.RoomPinnedEvents, - "", - )) as RoomPinnedEventsEventContent; - const pinned = content.pinned; - return pinned.filter((id) => id.trim().length > 0); - } catch (err: unknown) { - const errObj = err as { statusCode?: number; body?: { errcode?: string } }; - const httpStatus = errObj.statusCode; - const errcode = errObj.body?.errcode; - if (httpStatus === 404 || errcode === "M_NOT_FOUND") { - return []; - } - throw err; - } -} - -async function fetchEventSummary( - client: MatrixClient, - roomId: string, - eventId: string, -): Promise { - 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( - to: string, - content: string, - opts: MatrixActionClientOpts & { - mediaUrl?: string; - replyToId?: string; - threadId?: string; - } = {}, -) { - return await sendMessageMatrix(to, content, { - mediaUrl: opts.mediaUrl, - replyToId: opts.replyToId, - threadId: opts.threadId, - client: opts.client, - timeoutMs: opts.timeoutMs, - }); -} - -export async function editMatrixMessage( - roomId: string, - messageId: string, - content: string, - opts: MatrixActionClientOpts = {}, -) { - const trimmed = content.trim(); - if (!trimmed) throw new Error("Matrix edit requires content"); - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const newContent = { - msgtype: MsgType.Text, - body: trimmed, - } satisfies RoomMessageEventContent; - const payload: RoomMessageEventContent = { - msgtype: MsgType.Text, - body: `* ${trimmed}`, - "m.new_content": newContent, - "m.relates_to": { - rel_type: RelationType.Replace, - event_id: messageId, - }, - }; - const eventId = await client.sendMessage(resolvedRoom, payload); - return { eventId: eventId ?? null }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function deleteMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { reason?: string } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - await client.redactEvent(resolvedRoom, messageId, opts.reason); - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function readMatrixMessages( - roomId: string, - opts: MatrixActionClientOpts & { - limit?: number; - before?: string; - after?: string; - } = {}, -): Promise<{ - messages: MatrixMessageSummary[]; - nextBatch?: string | null; - prevBatch?: string | null; -}> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const limit = - typeof opts.limit === "number" && Number.isFinite(opts.limit) - ? Math.max(1, Math.floor(opts.limit)) - : 20; - 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.stop(); - } -} - -export async function listMatrixReactions( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { limit?: number } = {}, -): Promise { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const limit = - typeof opts.limit === "number" && Number.isFinite(opts.limit) - ? Math.max(1, Math.floor(opts.limit)) - : 100; - // 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.chunk) { - const content = event.content as ReactionEventContent; - const key = content["m.relates_to"]?.key; - if (!key) continue; - const sender = event.sender ?? ""; - const entry: MatrixReactionSummary = summaries.get(key) ?? { - key, - count: 0, - users: [], - }; - entry.count += 1; - if (sender && !entry.users.includes(sender)) { - entry.users.push(sender); - } - summaries.set(key, entry); - } - return Array.from(summaries.values()); - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function removeMatrixReactions( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts & { emoji?: string } = {}, -): Promise<{ removed: number }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const res = await client.doRequest( - "GET", - `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, - { dir: "b", limit: 200 }, - ) as { chunk: MatrixRawEvent[] }; - const userId = await client.getUserId(); - if (!userId) return { removed: 0 }; - const targetEmoji = opts.emoji?.trim(); - const toRemove = res.chunk - .filter((event) => event.sender === userId) - .filter((event) => { - if (!targetEmoji) return true; - const content = event.content as ReactionEventContent; - return content["m.relates_to"]?.key === targetEmoji; - }) - .map((event) => event.event_id) - .filter((id): id is string => Boolean(id)); - if (toRemove.length === 0) return { removed: 0 }; - await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); - return { removed: toRemove.length }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function pinMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[] }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - 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); - return { pinned: next }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function unpinMatrixMessage( - roomId: string, - messageId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[] }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - 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); - return { pinned: next }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function listMatrixPins( - roomId: string, - opts: MatrixActionClientOpts = {}, -): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - const pinned = await readPinnedEvents(client, resolvedRoom); - const events = ( - await Promise.all( - pinned.map(async (eventId) => { - try { - return await fetchEventSummary(client, resolvedRoom, eventId); - } catch { - return null; - } - }), - ) - ).filter((event): event is MatrixMessageSummary => Boolean(event)); - return { pinned, events }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function getMatrixMemberInfo( - userId: string, - opts: MatrixActionClientOpts & { roomId?: string } = {}, -) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; - // 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: null, // Would need separate room state query - powerLevel: null, // Would need separate power levels state query - displayName: profile?.displayname ?? null, - roomId: roomId ?? null, - }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { - const { client, stopOnDone } = await resolveActionClient(opts); - try { - const resolvedRoom = await resolveMatrixRoomId(client, roomId); - // 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, - topic, - canonicalAlias, - altAliases: [], // Would need separate query - memberCount, - }; - } finally { - if (stopOnDone) client.stop(); - } -} - -export { reactMatrixMessage }; +export type { + MatrixActionClientOpts, + MatrixMessageSummary, + MatrixReactionSummary, +} from "./actions/types.js"; +export { + sendMatrixMessage, + editMatrixMessage, + deleteMatrixMessage, + readMatrixMessages, +} from "./actions/messages.js"; +export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; +export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; +export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; +export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts new file mode 100644 index 0000000000..b9f27640c9 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -0,0 +1,53 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import type { CoreConfig } from "../types.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export async function resolveActionClient( + opts: MatrixActionClientOpts = {}, +): Promise { + ensureNodeRuntime(); + if (opts.client) return { client: opts.client, stopOnDone: false }; + const active = getActiveMatrixClient(); + if (active) return { client: active, stopOnDone: false }; + const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off actions. + } + } + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts new file mode 100644 index 0000000000..dae1a0f206 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -0,0 +1,120 @@ +import { + EventType, + MsgType, + RelationType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { summarizeMatrixRawEvent } from "./summary.js"; +import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; + +export async function sendMatrixMessage( + to: string, + content: string, + opts: MatrixActionClientOpts & { + mediaUrl?: string; + replyToId?: string; + threadId?: string; + } = {}, +) { + return await sendMessageMatrix(to, content, { + mediaUrl: opts.mediaUrl, + replyToId: opts.replyToId, + threadId: opts.threadId, + client: opts.client, + timeoutMs: opts.timeoutMs, + }); +} + +export async function editMatrixMessage( + roomId: string, + messageId: string, + content: string, + opts: MatrixActionClientOpts = {}, +) { + const trimmed = content.trim(); + if (!trimmed) throw new Error("Matrix edit requires content"); + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const newContent = { + msgtype: MsgType.Text, + body: trimmed, + } satisfies RoomMessageEventContent; + const payload: RoomMessageEventContent = { + msgtype: MsgType.Text, + body: `* ${trimmed}`, + "m.new_content": newContent, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: messageId, + }, + }; + const eventId = await client.sendMessage(resolvedRoom, payload); + return { eventId: eventId ?? null }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function deleteMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { reason?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + await client.redactEvent(resolvedRoom, messageId, opts.reason); + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function readMatrixMessages( + roomId: string, + opts: MatrixActionClientOpts & { + limit?: number; + before?: string; + after?: string; + } = {}, +): Promise<{ + messages: MatrixMessageSummary[]; + nextBatch?: string | null; + prevBatch?: string | null; +}> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 20; + 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.stop(); + } +} diff --git a/extensions/matrix/src/matrix/actions/pins.ts b/extensions/matrix/src/matrix/actions/pins.ts new file mode 100644 index 0000000000..a29dfba452 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/pins.ts @@ -0,0 +1,70 @@ +import { + EventType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type RoomPinnedEventsEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { fetchEventSummary, readPinnedEvents } from "./summary.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function pinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + 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); + return { pinned: next }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function unpinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + 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); + return { pinned: next }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function listMatrixPins( + roomId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const pinned = await readPinnedEvents(client, resolvedRoom); + const events = ( + await Promise.all( + pinned.map(async (eventId) => { + try { + return await fetchEventSummary(client, resolvedRoom, eventId); + } catch { + return null; + } + }), + ) + ).filter((event): event is MatrixMessageSummary => Boolean(event)); + return { pinned, events }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts new file mode 100644 index 0000000000..5c3f653052 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -0,0 +1,84 @@ +import { + EventType, + RelationType, + type MatrixActionClientOpts, + type MatrixRawEvent, + type MatrixReactionSummary, + type ReactionEventContent, +} from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function listMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { limit?: number } = {}, +): Promise { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 100; + // 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.chunk) { + const content = event.content as ReactionEventContent; + const key = content["m.relates_to"]?.key; + if (!key) continue; + const sender = event.sender ?? ""; + const entry: MatrixReactionSummary = summaries.get(key) ?? { + key, + count: 0, + users: [], + }; + entry.count += 1; + if (sender && !entry.users.includes(sender)) { + entry.users.push(sender); + } + summaries.set(key, entry); + } + return Array.from(summaries.values()); + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function removeMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { emoji?: string } = {}, +): Promise<{ removed: number }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const res = await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit: 200 }, + ) as { chunk: MatrixRawEvent[] }; + const userId = await client.getUserId(); + if (!userId) return { removed: 0 }; + const targetEmoji = opts.emoji?.trim(); + const toRemove = res.chunk + .filter((event) => event.sender === userId) + .filter((event) => { + if (!targetEmoji) return true; + const content = event.content as ReactionEventContent; + return content["m.relates_to"]?.key === targetEmoji; + }) + .map((event) => event.event_id) + .filter((id): id is string => Boolean(id)); + if (toRemove.length === 0) return { removed: 0 }; + await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); + return { removed: toRemove.length }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts new file mode 100644 index 0000000000..1b52404dc0 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -0,0 +1,88 @@ +import { EventType, type MatrixActionClientOpts } from "./types.js"; +import { resolveActionClient } from "./client.js"; +import { resolveMatrixRoomId } from "../send.js"; + +export async function getMatrixMemberInfo( + userId: string, + opts: MatrixActionClientOpts & { roomId?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; + // 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: null, // Would need separate room state query + powerLevel: null, // Would need separate power levels state query + displayName: profile?.displayname ?? null, + roomId: roomId ?? null, + }; + } finally { + if (stopOnDone) client.stop(); + } +} + +export async function getMatrixRoomInfo( + roomId: string, + opts: MatrixActionClientOpts = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + // 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, + topic, + canonicalAlias, + altAliases: [], // Would need separate query + memberCount, + }; + } finally { + if (stopOnDone) client.stop(); + } +} diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts new file mode 100644 index 0000000000..f58d6a9b87 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -0,0 +1,77 @@ +import type { MatrixClient } from "matrix-bot-sdk"; + +import { + EventType, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, + type RoomPinnedEventsEventContent, +} from "./types.js"; + +export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary { + const content = event.content as RoomMessageEventContent; + const relates = content["m.relates_to"]; + let relType: string | undefined; + let eventId: string | undefined; + if (relates) { + if ("rel_type" in relates) { + relType = relates.rel_type; + eventId = relates.event_id; + } else if ("m.in_reply_to" in relates) { + eventId = relates["m.in_reply_to"]?.event_id; + } + } + const relatesTo = + relType || eventId + ? { + relType, + eventId, + } + : undefined; + return { + eventId: event.event_id, + sender: event.sender, + body: content.body, + msgtype: content.msgtype, + timestamp: event.origin_server_ts, + relatesTo, + }; +} + +export async function readPinnedEvents( + client: MatrixClient, + roomId: string, +): Promise { + try { + const content = (await client.getRoomStateEvent( + roomId, + EventType.RoomPinnedEvents, + "", + )) as RoomPinnedEventsEventContent; + const pinned = content.pinned; + return pinned.filter((id) => id.trim().length > 0); + } catch (err: unknown) { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + const httpStatus = errObj.statusCode; + const errcode = errObj.body?.errcode; + if (httpStatus === 404 || errcode === "M_NOT_FOUND") { + return []; + } + throw err; + } +} + +export async function fetchEventSummary( + client: MatrixClient, + roomId: string, + eventId: string, +): Promise { + try { + const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent; + if (raw.unsigned?.redacted_because) return null; + return summarizeMatrixRawEvent(raw); + } catch { + // Event not found, redacted, or inaccessible - return null + return null; + } +} diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts new file mode 100644 index 0000000000..506e007838 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -0,0 +1,84 @@ +import type { MatrixClient } from "matrix-bot-sdk"; + +export const MsgType = { + Text: "m.text", +} as const; + +export const RelationType = { + Replace: "m.replace", + Annotation: "m.annotation", +} as const; + +export const EventType = { + RoomMessage: "m.room.message", + RoomPinnedEvents: "m.room.pinned_events", + RoomTopic: "m.room.topic", + Reaction: "m.reaction", +} as const; + +export type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.new_content"?: RoomMessageEventContent; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +}; + +export type RoomPinnedEventsEventContent = { + pinned: string[]; +}; + +export type RoomTopicEventContent = { + topic?: string; +}; + +export type 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; +}; + +export type MatrixMessageSummary = { + eventId?: string; + sender?: string; + body?: string; + msgtype?: string; + timestamp?: number; + relatesTo?: { + relType?: string; + eventId?: string; + key?: string; + }; +}; + +export type MatrixReactionSummary = { + key: string; + count: number; + users: string[]; +}; + +export type MatrixActionClient = { + client: MatrixClient; + stopOnDone: boolean; +}; diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts index 02a0869707..82c734d15b 100644 --- a/extensions/matrix/src/matrix/client.ts +++ b/extensions/matrix/src/matrix/client.ts @@ -1,645 +1,9 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -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"; - -export type MatrixResolvedConfig = { - homeserver: string; - userId: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - encryption?: boolean; -}; - -/** - * Authenticated Matrix configuration. - * Note: deviceId is NOT included here because it's implicit in the accessToken. - * The crypto storage assumes the device ID (and thus access token) does not change - * between restarts. If the access token becomes invalid or crypto storage is lost, - * both will need to be recreated together. - */ -export type MatrixAuth = { - homeserver: string; - userId: string; - accessToken: string; - deviceName?: string; - initialSyncLimit?: number; - encryption?: boolean; -}; - -type SharedMatrixClientState = { - client: MatrixClient; - key: string; - started: boolean; - cryptoReady: boolean; -}; - -let sharedClientState: SharedMatrixClientState | null = null; -let sharedClientPromise: Promise | null = null; -let sharedClientStartPromise: Promise | null = null; - -export function isBunRuntime(): boolean { - const versions = process.versions as { bun?: string }; - return typeof versions.bun === "string"; -} - -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() ?? ""; -} - -const DEFAULT_ACCOUNT_KEY = "default"; -const STORAGE_META_FILENAME = "storage-meta.json"; - -type MatrixStoragePaths = { - rootDir: string; - storagePath: string; - cryptoPath: string; - metaPath: string; - accountKey: string; - tokenHash: string; -}; - -function sanitizePathSegment(value: string): string { - const cleaned = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "_") - .replace(/^_+|_+$/g, ""); - return cleaned || "unknown"; -} - -function resolveHomeserverKey(homeserver: string): string { - try { - const url = new URL(homeserver); - if (url.host) return sanitizePathSegment(url.host); - } catch { - // fall through - } - return sanitizePathSegment(homeserver); -} - -function hashAccessToken(accessToken: string): string { - return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); -} - -function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { - storagePath: string; - cryptoPath: string; -} { - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - return { - storagePath: path.join(stateDir, "matrix", "bot-storage.json"), - cryptoPath: path.join(stateDir, "matrix", "crypto"), - }; -} - -function resolveMatrixStoragePaths(params: { - homeserver: string; - userId: string; - accessToken: string; - accountId?: string | null; - env?: NodeJS.ProcessEnv; -}): MatrixStoragePaths { - const env = params.env ?? process.env; - const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); - const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); - const userKey = sanitizePathSegment(params.userId); - const serverKey = resolveHomeserverKey(params.homeserver); - const tokenHash = hashAccessToken(params.accessToken); - const rootDir = path.join( - stateDir, - "matrix", - "accounts", - accountKey, - `${serverKey}__${userKey}`, - tokenHash, - ); - return { - rootDir, - storagePath: path.join(rootDir, "bot-storage.json"), - cryptoPath: path.join(rootDir, "crypto"), - metaPath: path.join(rootDir, STORAGE_META_FILENAME), - accountKey, - tokenHash, - }; -} - -function maybeMigrateLegacyStorage(params: { - storagePaths: MatrixStoragePaths; - env?: NodeJS.ProcessEnv; -}): void { - const legacy = resolveLegacyStoragePaths(params.env); - const hasLegacyStorage = fs.existsSync(legacy.storagePath); - const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); - const hasNewStorage = - fs.existsSync(params.storagePaths.storagePath) || - fs.existsSync(params.storagePaths.cryptoPath); - - if (!hasLegacyStorage && !hasLegacyCrypto) return; - if (hasNewStorage) return; - - fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); - if (hasLegacyStorage) { - try { - fs.renameSync(legacy.storagePath, params.storagePaths.storagePath); - } catch { - // Ignore migration failures; new store will be created. - } - } - if (hasLegacyCrypto) { - try { - fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath); - } catch { - // Ignore migration failures; new store will be created. - } - } -} - -function writeStorageMeta(params: { - storagePaths: MatrixStoragePaths; - homeserver: string; - userId: string; - accountId?: string | null; -}): void { - try { - const payload = { - homeserver: params.homeserver, - userId: params.userId, - accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, - accessTokenHash: params.storagePaths.tokenHash, - createdAt: new Date().toISOString(), - }; - fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); - fs.writeFileSync( - params.storagePaths.metaPath, - JSON.stringify(payload, null, 2), - "utf-8", - ); - } catch { - // ignore meta write failures - } -} - -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, -): MatrixResolvedConfig { - const matrix = cfg.channels?.matrix ?? {}; - const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); - const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); - const accessToken = - clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; - const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; - const deviceName = - clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; - const initialSyncLimit = - typeof matrix.initialSyncLimit === "number" - ? Math.max(0, Math.floor(matrix.initialSyncLimit)) - : undefined; - const encryption = matrix.encryption ?? false; - return { - homeserver, - userId, - accessToken, - password, - deviceName, - initialSyncLimit, - encryption, - }; -} - -export async function resolveMatrixAuth(params?: { - cfg?: CoreConfig; - env?: NodeJS.ProcessEnv; -}): Promise { - const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); - const env = params?.env ?? process.env; - const resolved = resolveMatrixConfig(cfg, env); - if (!resolved.homeserver) { - throw new Error("Matrix homeserver is required (matrix.homeserver)"); - } - - const { - loadMatrixCredentials, - saveMatrixCredentials, - credentialsMatchConfig, - touchMatrixCredentials, - } = await import("./credentials.js"); - - const cached = loadMatrixCredentials(env); - const cachedCredentials = - cached && - credentialsMatchConfig(cached, { - homeserver: resolved.homeserver, - userId: resolved.userId || "", - }) - ? cached - : null; - - // If we have an access token, we can fetch userId via whoami if not provided - if (resolved.accessToken) { - let userId = resolved.userId; - if (!userId) { - // Fetch userId from access token via whoami - 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, - accessToken: resolved.accessToken, - deviceName: resolved.deviceName, - initialSyncLimit: resolved.initialSyncLimit, - encryption: resolved.encryption, - }; - } - - if (cachedCredentials) { - touchMatrixCredentials(env); - return { - homeserver: cachedCredentials.homeserver, - userId: cachedCredentials.userId, - 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 password is required when no access token is configured (matrix.password)", - ); - } - - // 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"); - } - - const auth: MatrixAuth = { - homeserver: resolved.homeserver, - userId: login.user_id ?? resolved.userId, - 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; -} - -export async function createMatrixClient(params: { - homeserver: string; - userId: string; - accessToken: string; - encryption?: boolean; - localTimeoutMs?: number; - accountId?: string | null; -}): Promise { - ensureMatrixSdkLoggingConfigured(); - const env = process.env; - - // Create storage provider - const storagePaths = resolveMatrixStoragePaths({ - homeserver: params.homeserver, - userId: params.userId, - accessToken: params.accessToken, - accountId: params.accountId, - env, - }); - maybeMigrateLegacyStorage({ storagePaths, env }); - fs.mkdirSync(storagePaths.rootDir, { recursive: true }); - const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath); - - // Create crypto storage if encryption is enabled - let cryptoStorage: ICryptoStorageProvider | undefined; - if (params.encryption) { - fs.mkdirSync(storagePaths.cryptoPath, { recursive: true }); - - try { - const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); - cryptoStorage = new RustSdkCryptoStorageProvider( - storagePaths.cryptoPath, - StoreType.Sqlite, - ); - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err); - } - } - - writeStorageMeta({ - storagePaths, - homeserver: params.homeserver, - userId: params.userId, - accountId: params.accountId, - }); - - 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, accountId?: string | null): string { - return [ - auth.homeserver, - auth.userId, - auth.accessToken, - auth.encryption ? "e2ee" : "plain", - accountId ?? DEFAULT_ACCOUNT_KEY, - ].join("|"); -} - -async function createSharedMatrixClient(params: { - auth: MatrixAuth; - timeoutMs?: number; - accountId?: string | null; -}): Promise { - const client = await createMatrixClient({ - homeserver: params.auth.homeserver, - userId: params.auth.userId, - accessToken: params.auth.accessToken, - encryption: params.auth.encryption, - localTimeoutMs: params.timeoutMs, - accountId: params.accountId, - }); - return { - client, - key: buildSharedClientKey(params.auth, params.accountId), - started: false, - cryptoReady: false, - }; -} - -async function ensureSharedClientStarted(params: { - state: SharedMatrixClientState; - timeoutMs?: number; - initialSyncLimit?: number; - encryption?: boolean; -}): Promise { - if (params.state.started) return; - if (sharedClientStartPromise) { - await sharedClientStartPromise; - return; - } - sharedClientStartPromise = (async () => { - const client = params.state.client; - - // Initialize crypto if enabled - if (params.encryption && !params.state.cryptoReady) { - try { - const joinedRooms = await client.getJoinedRooms(); - if (client.crypto) { - await client.crypto.prepare(joinedRooms); - params.state.cryptoReady = true; - } - } catch (err) { - LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); - } - } - - await client.start(); - params.state.started = true; - })(); - try { - await sharedClientStartPromise; - } finally { - sharedClientStartPromise = null; - } -} - -export async function resolveSharedMatrixClient( - params: { - cfg?: CoreConfig; - env?: NodeJS.ProcessEnv; - timeoutMs?: number; - auth?: MatrixAuth; - startClient?: boolean; - accountId?: string | null; - } = {}, -): Promise { - const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env })); - const key = buildSharedClientKey(auth, params.accountId); - const shouldStart = params.startClient !== false; - - if (sharedClientState?.key === key) { - if (shouldStart) { - await ensureSharedClientStarted({ - state: sharedClientState, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return sharedClientState.client; - } - - if (sharedClientPromise) { - const pending = await sharedClientPromise; - if (pending.key === key) { - if (shouldStart) { - await ensureSharedClientStarted({ - state: pending, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return pending.client; - } - pending.client.stop(); - sharedClientState = null; - sharedClientPromise = null; - } - - sharedClientPromise = createSharedMatrixClient({ - auth, - timeoutMs: params.timeoutMs, - accountId: params.accountId, - }); - try { - const created = await sharedClientPromise; - sharedClientState = created; - if (shouldStart) { - await ensureSharedClientStarted({ - state: created, - timeoutMs: params.timeoutMs, - initialSyncLimit: auth.initialSyncLimit, - encryption: auth.encryption, - }); - } - return created.client; - } finally { - sharedClientPromise = null; - } -} - -export async function waitForMatrixSync(_params: { - client: MatrixClient; - timeoutMs?: number; - abortSignal?: AbortSignal; -}): Promise { - // matrix-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; - } -} +export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; +export { isBunRuntime } from "./client/runtime.js"; +export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js"; +export { createMatrixClient } from "./client/create-client.js"; +export { + resolveSharedMatrixClient, + waitForMatrixSync, + stopSharedClient, +} from "./client/shared.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts new file mode 100644 index 0000000000..9780988734 --- /dev/null +++ b/extensions/matrix/src/matrix/client/config.ts @@ -0,0 +1,165 @@ +import { MatrixClient } from "matrix-bot-sdk"; + +import type { CoreConfig } from "../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; + +function clean(value?: string): string { + return value?.trim() ?? ""; +} + +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): MatrixResolvedConfig { + const matrix = cfg.channels?.matrix ?? {}; + const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); + const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); + const accessToken = + clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; + const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; + const deviceName = + clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; + const initialSyncLimit = + typeof matrix.initialSyncLimit === "number" + ? Math.max(0, Math.floor(matrix.initialSyncLimit)) + : undefined; + const encryption = matrix.encryption ?? false; + return { + homeserver, + userId, + accessToken, + password, + deviceName, + initialSyncLimit, + encryption, + }; +} + +export async function resolveMatrixAuth(params?: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; +}): Promise { + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const env = params?.env ?? process.env; + const resolved = resolveMatrixConfig(cfg, env); + if (!resolved.homeserver) { + throw new Error("Matrix homeserver is required (matrix.homeserver)"); + } + + const { + loadMatrixCredentials, + saveMatrixCredentials, + credentialsMatchConfig, + touchMatrixCredentials, + } = await import("./credentials.js"); + + const cached = loadMatrixCredentials(env); + const cachedCredentials = + cached && + credentialsMatchConfig(cached, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + ? cached + : null; + + // If we have an access token, we can fetch userId via whoami if not provided + if (resolved.accessToken) { + let userId = resolved.userId; + if (!userId) { + // Fetch userId from access token via whoami + 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, + accessToken: resolved.accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + if (cachedCredentials) { + touchMatrixCredentials(env); + return { + homeserver: cachedCredentials.homeserver, + userId: cachedCredentials.userId, + 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 password is required when no access token is configured (matrix.password)", + ); + } + + // 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"); + } + + const auth: MatrixAuth = { + homeserver: resolved.homeserver, + userId: login.user_id ?? resolved.userId, + 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; +} diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts new file mode 100644 index 0000000000..01dc2e7ad5 --- /dev/null +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; + +import { + LogService, + MatrixClient, + SimpleFsStorageProvider, + RustSdkCryptoStorageProvider, +} from "matrix-bot-sdk"; +import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk"; + +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import { + maybeMigrateLegacyStorage, + resolveMatrixStoragePaths, + writeStorageMeta, +} from "./storage.js"; + +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 async function createMatrixClient(params: { + homeserver: string; + userId: string; + accessToken: string; + encryption?: boolean; + localTimeoutMs?: number; + accountId?: string | null; +}): Promise { + ensureMatrixSdkLoggingConfigured(); + const env = process.env; + + // Create storage provider + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + accountId: params.accountId, + env, + }); + maybeMigrateLegacyStorage({ storagePaths, env }); + fs.mkdirSync(storagePaths.rootDir, { recursive: true }); + const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath); + + // Create crypto storage if encryption is enabled + let cryptoStorage: ICryptoStorageProvider | undefined; + if (params.encryption) { + fs.mkdirSync(storagePaths.cryptoPath, { recursive: true }); + + try { + const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); + cryptoStorage = new RustSdkCryptoStorageProvider( + storagePaths.cryptoPath, + StoreType.Sqlite, + ); + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err); + } + } + + writeStorageMeta({ + storagePaths, + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId, + }); + + 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; +} diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts new file mode 100644 index 0000000000..7c4011fc5d --- /dev/null +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -0,0 +1,35 @@ +import { ConsoleLogger, LogService } from "matrix-bot-sdk"; + +let matrixSdkLoggingConfigured = false; +const matrixSdkBaseLogger = new ConsoleLogger(); + +function shouldSuppressMatrixHttpNotFound( + module: string, + messageOrObject: unknown[], +): boolean { + if (module !== "MatrixHttpClient") return false; + return messageOrObject.some((entry) => { + if (!entry || typeof entry !== "object") return false; + return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; + }); +} + +export function ensureMatrixSdkLoggingConfigured(): void { + if (matrixSdkLoggingConfigured) return; + matrixSdkLoggingConfigured = true; + + LogService.setLogger({ + trace: (module, ...messageOrObject) => + matrixSdkBaseLogger.trace(module, ...messageOrObject), + debug: (module, ...messageOrObject) => + matrixSdkBaseLogger.debug(module, ...messageOrObject), + info: (module, ...messageOrObject) => + matrixSdkBaseLogger.info(module, ...messageOrObject), + warn: (module, ...messageOrObject) => + matrixSdkBaseLogger.warn(module, ...messageOrObject), + error: (module, ...messageOrObject) => { + if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return; + matrixSdkBaseLogger.error(module, ...messageOrObject); + }, + }); +} diff --git a/extensions/matrix/src/matrix/client/runtime.ts b/extensions/matrix/src/matrix/client/runtime.ts new file mode 100644 index 0000000000..4995eaf8d5 --- /dev/null +++ b/extensions/matrix/src/matrix/client/runtime.ts @@ -0,0 +1,4 @@ +export function isBunRuntime(): boolean { + const versions = process.versions as { bun?: string }; + return typeof versions.bun === "string"; +} diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts new file mode 100644 index 0000000000..fcde282685 --- /dev/null +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -0,0 +1,169 @@ +import { LogService } from "matrix-bot-sdk"; +import type { MatrixClient } from "matrix-bot-sdk"; + +import type { CoreConfig } from "../types.js"; +import { createMatrixClient } from "./create-client.js"; +import { resolveMatrixAuth } from "./config.js"; +import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; +import type { MatrixAuth } from "./types.js"; + +type SharedMatrixClientState = { + client: MatrixClient; + key: string; + started: boolean; + cryptoReady: boolean; +}; + +let sharedClientState: SharedMatrixClientState | null = null; +let sharedClientPromise: Promise | null = null; +let sharedClientStartPromise: Promise | null = null; + +function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { + return [ + auth.homeserver, + auth.userId, + auth.accessToken, + auth.encryption ? "e2ee" : "plain", + accountId ?? DEFAULT_ACCOUNT_KEY, + ].join("|"); +} + +async function createSharedMatrixClient(params: { + auth: MatrixAuth; + timeoutMs?: number; + accountId?: string | null; +}): Promise { + const client = await createMatrixClient({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + encryption: params.auth.encryption, + localTimeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + return { + client, + key: buildSharedClientKey(params.auth, params.accountId), + started: false, + cryptoReady: false, + }; +} + +async function ensureSharedClientStarted(params: { + state: SharedMatrixClientState; + timeoutMs?: number; + initialSyncLimit?: number; + encryption?: boolean; +}): Promise { + if (params.state.started) return; + if (sharedClientStartPromise) { + await sharedClientStartPromise; + return; + } + sharedClientStartPromise = (async () => { + const client = params.state.client; + + // Initialize crypto if enabled + if (params.encryption && !params.state.cryptoReady) { + try { + const joinedRooms = await client.getJoinedRooms(); + if (client.crypto) { + await client.crypto.prepare(joinedRooms); + params.state.cryptoReady = true; + } + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); + } + } + + await client.start(); + params.state.started = true; + })(); + try { + await sharedClientStartPromise; + } finally { + sharedClientStartPromise = null; + } +} + +export async function resolveSharedMatrixClient( + params: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + auth?: MatrixAuth; + startClient?: boolean; + accountId?: string | null; + } = {}, +): Promise { + const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env })); + const key = buildSharedClientKey(auth, params.accountId); + const shouldStart = params.startClient !== false; + + if (sharedClientState?.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: sharedClientState, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return sharedClientState.client; + } + + if (sharedClientPromise) { + const pending = await sharedClientPromise; + if (pending.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return pending.client; + } + pending.client.stop(); + sharedClientState = null; + sharedClientPromise = null; + } + + sharedClientPromise = createSharedMatrixClient({ + auth, + timeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + try { + const created = await sharedClientPromise; + sharedClientState = created; + if (shouldStart) { + await ensureSharedClientStarted({ + state: created, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return created.client; + } finally { + sharedClientPromise = null; + } +} + +export async function waitForMatrixSync(_params: { + client: MatrixClient; + timeoutMs?: number; + abortSignal?: AbortSignal; +}): Promise { + // matrix-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/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts new file mode 100644 index 0000000000..b5ddffe887 --- /dev/null +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -0,0 +1,131 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { getMatrixRuntime } from "../../runtime.js"; +import type { MatrixStoragePaths } from "./types.js"; + +export const DEFAULT_ACCOUNT_KEY = "default"; +const STORAGE_META_FILENAME = "storage-meta.json"; + +function sanitizePathSegment(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return cleaned || "unknown"; +} + +function resolveHomeserverKey(homeserver: string): string { + try { + const url = new URL(homeserver); + if (url.host) return sanitizePathSegment(url.host); + } catch { + // fall through + } + return sanitizePathSegment(homeserver); +} + +function hashAccessToken(accessToken: string): string { + return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); +} + +function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { + storagePath: string; + cryptoPath: string; +} { + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return { + storagePath: path.join(stateDir, "matrix", "bot-storage.json"), + cryptoPath: path.join(stateDir, "matrix", "crypto"), + }; +} + +export function resolveMatrixStoragePaths(params: { + homeserver: string; + userId: string; + accessToken: string; + accountId?: string | null; + env?: NodeJS.ProcessEnv; +}): MatrixStoragePaths { + const env = params.env ?? process.env; + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); + const userKey = sanitizePathSegment(params.userId); + const serverKey = resolveHomeserverKey(params.homeserver); + const tokenHash = hashAccessToken(params.accessToken); + const rootDir = path.join( + stateDir, + "matrix", + "accounts", + accountKey, + `${serverKey}__${userKey}`, + tokenHash, + ); + return { + rootDir, + storagePath: path.join(rootDir, "bot-storage.json"), + cryptoPath: path.join(rootDir, "crypto"), + metaPath: path.join(rootDir, STORAGE_META_FILENAME), + accountKey, + tokenHash, + }; +} + +export function maybeMigrateLegacyStorage(params: { + storagePaths: MatrixStoragePaths; + env?: NodeJS.ProcessEnv; +}): void { + const legacy = resolveLegacyStoragePaths(params.env); + const hasLegacyStorage = fs.existsSync(legacy.storagePath); + const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); + const hasNewStorage = + fs.existsSync(params.storagePaths.storagePath) || + fs.existsSync(params.storagePaths.cryptoPath); + + if (!hasLegacyStorage && !hasLegacyCrypto) return; + if (hasNewStorage) return; + + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + if (hasLegacyStorage) { + try { + fs.renameSync(legacy.storagePath, params.storagePaths.storagePath); + } catch { + // Ignore migration failures; new store will be created. + } + } + if (hasLegacyCrypto) { + try { + fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath); + } catch { + // Ignore migration failures; new store will be created. + } + } +} + +export function writeStorageMeta(params: { + storagePaths: MatrixStoragePaths; + homeserver: string; + userId: string; + accountId?: string | null; +}): void { + try { + const payload = { + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, + accessTokenHash: params.storagePaths.tokenHash, + createdAt: new Date().toISOString(), + }; + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + fs.writeFileSync( + params.storagePaths.metaPath, + JSON.stringify(payload, null, 2), + "utf-8", + ); + } catch { + // ignore meta write failures + } +} diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts new file mode 100644 index 0000000000..ec1b3002bc --- /dev/null +++ b/extensions/matrix/src/matrix/client/types.ts @@ -0,0 +1,34 @@ +export type MatrixResolvedConfig = { + homeserver: string; + userId: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +/** + * Authenticated Matrix configuration. + * Note: deviceId is NOT included here because it's implicit in the accessToken. + * The crypto storage assumes the device ID (and thus access token) does not change + * between restarts. If the access token becomes invalid or crypto storage is lost, + * both will need to be recreated together. + */ +export type MatrixAuth = { + homeserver: string; + userId: string; + accessToken: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +export type MatrixStoragePaths = { + rootDir: string; + storagePath: string; + cryptoPath: string; + metaPath: string; + accountKey: string; + tokenHash: string; +}; diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts new file mode 100644 index 0000000000..af49693ff0 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -0,0 +1,103 @@ +import type { MatrixClient } from "matrix-bot-sdk"; +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +import type { MatrixAuth } from "../client.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +export function registerMatrixMonitorEvents(params: { + client: MatrixClient; + auth: MatrixAuth; + logVerboseMessage: (message: string) => void; + warnedEncryptedRooms: Set; + warnedCryptoMissingRooms: Set; + logger: { warn: (meta: Record, message: string) => void }; + formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; + onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; +}): void { + const { + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint, + onRoomMessage, + } = params; + + client.on("room.message", onRoomMessage); + + client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on( + "room.failed_decryption", + async (roomId: string, event: MatrixRawEvent, error: Error) => { + logger.warn( + { roomId, eventId: event.event_id, error: error.message }, + "Failed to decrypt message", + ); + logVerboseMessage( + `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, + ); + }, + ); + + client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const sender = event?.sender ?? "unknown"; + const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; + logVerboseMessage( + `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, + ); + }); + + client.on("room.join", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`); + }); + + client.on("room.event", (roomId: string, event: MatrixRawEvent) => { + const eventType = event?.type ?? "unknown"; + if (eventType === EventType.RoomMessageEncrypted) { + logVerboseMessage( + `matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`, + ); + if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { + warnedEncryptedRooms.add(roomId); + const warning = + "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; + logger.warn({ roomId }, warning); + } + if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { + warnedCryptoMissingRooms.add(roomId); + const hint = formatNativeDependencyHint({ + packageName: "@matrix-org/matrix-sdk-crypto-nodejs", + manager: "pnpm", + downloadCommand: + "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", + }); + const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; + logger.warn({ roomId }, warning); + } + return; + } + if (eventType === EventType.RoomMember) { + const membership = (event?.content as { membership?: string } | undefined)?.membership; + const stateKey = (event as { state_key?: string }).state_key ?? ""; + logVerboseMessage( + `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, + ); + } + }); +} diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts new file mode 100644 index 0000000000..62a7a2c268 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -0,0 +1,604 @@ +import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; + +import { + formatAllowlistMatchMeta, + type RuntimeEnv, +} from "clawdbot/plugin-sdk"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; +import { + formatPollAsText, + isPollStartType, + parsePollStartContent, + type PollStartContent, +} from "../poll-types.js"; +import { reactMatrixMessage, sendMessageMatrix, sendReadReceiptMatrix, sendTypingMatrix } from "../send.js"; +import { + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, + normalizeAllowListLower, +} from "./allowlist.js"; +import { downloadMatrixMedia } from "./media.js"; +import { resolveMentions } from "./mentions.js"; +import { deliverMatrixReplies } from "./replies.js"; +import { resolveMatrixRoomConfig } from "./rooms.js"; +import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; +import { EventType, RelationType } from "./types.js"; + +export type MatrixMonitorHandlerParams = { + client: MatrixClient; + core: { + logging: { + shouldLogVerbose: () => boolean; + }; + channel: typeof import("clawdbot/plugin-sdk")["channel"]; + system: { + enqueueSystemEvent: ( + text: string, + meta: { sessionKey?: string | null; contextKey?: string | null }, + ) => void; + }; + }; + cfg: CoreConfig; + runtime: RuntimeEnv; + logger: { + info: (message: string | Record, ...meta: unknown[]) => void; + warn: (meta: Record, message: string) => void; + }; + logVerboseMessage: (message: string) => void; + allowFrom: string[]; + roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig } + ? MatrixConfig extends { groups?: infer Groups } + ? Groups + : Record | undefined + : Record | undefined; + mentionRegexes: ReturnType< + typeof import("clawdbot/plugin-sdk")["channel"]["mentions"]["buildMentionRegexes"] + >; + groupPolicy: "open" | "allowlist" | "disabled"; + replyToMode: ReplyToMode; + threadReplies: "off" | "inbound" | "always"; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + textLimit: number; + mediaMaxBytes: number; + startupMs: number; + startupGraceMs: number; + directTracker: { + isDirectMessage: (params: { + roomId: string; + senderId: string; + selfUserId: string; + }) => Promise; + }; + getRoomInfo: (roomId: string) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; + getMemberDisplayName: (roomId: string, userId: string) => Promise; +}; + +export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { + const { + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + } = params; + + return async (roomId: string, event: MatrixRawEvent) => { + try { + const eventType = event.type; + if (eventType === EventType.RoomMessageEncrypted) { + // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled + return; + } + + const isPollEvent = isPollStartType(eventType); + const locationContent = event.content as LocationMessageEventContent; + const isLocationEvent = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && + locationContent.msgtype === EventType.Location); + if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return; + logVerboseMessage( + `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, + ); + if (event.unsigned?.redacted_because) return; + const senderId = event.sender; + if (!senderId) return; + const selfUserId = await client.getUserId(); + if (senderId === selfUserId) return; + const eventTs = event.origin_server_ts; + const eventAge = event.unsigned?.age; + if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { + return; + } + if ( + typeof eventTs !== "number" && + typeof eventAge === "number" && + eventAge > startupGraceMs + ) { + return; + } + + const roomInfo = await getRoomInfo(roomId); + const roomName = roomInfo.name; + const roomAliases = [ + roomInfo.canonicalAlias ?? "", + ...roomInfo.altAliases, + ].filter(Boolean); + + let content = event.content as RoomMessageEventContent; + if (isPollEvent) { + const pollStartContent = event.content as PollStartContent; + const pollSummary = parsePollStartContent(pollStartContent); + if (pollSummary) { + pollSummary.eventId = event.event_id ?? ""; + pollSummary.roomId = roomId; + pollSummary.sender = senderId; + const senderDisplayName = await getMemberDisplayName(roomId, senderId); + pollSummary.senderName = senderDisplayName; + const pollText = formatPollAsText(pollSummary); + content = { + msgtype: "m.text", + body: pollText, + } as unknown as RoomMessageEventContent; + } else { + return; + } + } + + const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ + eventType, + content: content as LocationMessageEventContent, + }); + + const relates = content["m.relates_to"]; + if (relates && "rel_type" in relates) { + if (relates.rel_type === RelationType.Replace) return; + } + + const isDirectMessage = await directTracker.isDirectMessage({ + roomId, + senderId, + selfUserId, + }); + const isRoom = !isDirectMessage; + + if (isRoom && groupPolicy === "disabled") return; + + const roomConfigInfo = isRoom + ? resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliases, + name: roomName, + }) + : undefined; + const roomConfig = roomConfigInfo?.config; + const roomMatchMeta = roomConfigInfo + ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ + roomConfigInfo.matchSource ?? "none" + }` + : "matchKey=none matchSource=none"; + + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + return; + } + if (isRoom && groupPolicy === "allowlist") { + if (!roomConfigInfo?.allowlistConfigured) { + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + return; + } + if (!roomConfig) { + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + return; + } + } + + const senderName = await getMemberDisplayName(roomId, senderId); + const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); + const effectiveAllowFrom = 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; + if (dmPolicy !== "open") { + const allowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveAllowFrom, + userId: senderId, + userName: senderName, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (!allowMatch.allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + meta: { name: senderName }, + }); + if (created) { + logVerboseMessage( + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "Clawdbot: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "clawdbot pairing approve matrix ", + ].join("\n"), + { client }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } + if (dmPolicy !== "pairing") { + logVerboseMessage( + `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + } + return; + } + } + } + + const roomUsers = roomConfig?.users ?? []; + if (isRoom && roomUsers.length > 0) { + const userMatch = resolveMatrixAllowListMatch({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }); + if (!userMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + userMatch, + )})`, + ); + return; + } + } + if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + const groupAllowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + 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 = locationPayload?.text + ?? (typeof content.body === "string" ? content.body.trim() : ""); + let media: { + path: string; + contentType?: string; + placeholder: string; + } | null = null; + const contentUrl = + "url" in content && typeof content.url === "string" ? content.url : undefined; + const contentFile = + "file" in content && content.file && typeof content.file === "object" + ? content.file + : undefined; + const mediaUrl = contentUrl ?? contentFile?.url; + if (!rawBody && !mediaUrl) { + return; + } + + const contentType = + "info" in content && content.info && "mimetype" in content.info + ? (content.info as { mimetype?: string }).mimetype + : undefined; + if (mediaUrl?.startsWith("mxc://")) { + try { + media = await downloadMatrixMedia({ + client, + mxcUrl: mediaUrl, + contentType, + maxBytes: mediaMaxBytes, + file: contentFile, + }); + } catch (err) { + logVerboseMessage(`matrix: media download failed: ${String(err)}`); + } + } + + const bodyText = rawBody || media?.placeholder || ""; + if (!bodyText) return; + + const { wasMentioned, hasExplicitMention } = resolveMentions({ + content, + userId: selfUserId, + text: bodyText, + mentionRegexes, + }); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "matrix", + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveMatrixAllowListMatches({ + allowList: effectiveAllowFrom, + userId: senderId, + 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 ( + isRoom && + allowTextCommands && + core.channel.text.hasControlCommand(bodyText, cfg) && + !commandAuthorized + ) { + logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); + return; + } + const shouldRequireMention = isRoom + ? roomConfig?.autoReply === true + ? false + : roomConfig?.autoReply === false + ? true + : typeof roomConfig?.requireMention === "boolean" + ? roomConfig?.requireMention + : true + : false; + const shouldBypassMention = + allowTextCommands && + isRoom && + shouldRequireMention && + !wasMentioned && + !hasExplicitMention && + commandAuthorized && + core.channel.text.hasControlCommand(bodyText); + if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { + logger.info({ roomId, reason: "no-mention" }, "skipping room message"); + return; + } + + const messageId = event.event_id ?? ""; + const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; + const threadRootId = resolveMatrixThreadRootId({ event, content }); + const threadTarget = resolveMatrixThreadTarget({ + threadReplies, + messageId, + threadRootId, + isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available + }); + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix", + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); + const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); + const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Matrix", + from: envelopeFrom, + timestamp: eventTs ?? undefined, + previousTimestamp, + envelope: envelopeOptions, + body: textWithId, + }); + + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: bodyText, + CommandBody: bodyText, + From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, + To: `room:${roomId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: envelopeFrom, + SenderName: senderName, + SenderId: senderId, + SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), + GroupSubject: isRoom ? (roomName ?? roomId) : undefined, + GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, + GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, + Provider: "matrix" as const, + Surface: "matrix" as const, + WasMentioned: isRoom ? wasMentioned : undefined, + MessageSid: messageId, + ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), + MessageThreadId: threadTarget, + Timestamp: eventTs ?? undefined, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + ...(locationPayload?.context ?? {}), + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + OriginatingChannel: "matrix" as const, + OriginatingTo: `room:${roomId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + logger.warn( + { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, + "failed updating session meta", + ); + }); + + if (isDirectMessage) { + await core.channel.session.updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: route.accountId, + ctx: ctxPayload, + }); + } + + const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackScope === "all") return true; + if (ackScope === "direct") return isDirectMessage; + if (ackScope === "group-all") return isRoom; + if (ackScope === "group-mentions") { + if (!isRoom) return false; + if (!shouldRequireMention) return false; + return wasMentioned || shouldBypassMention; + } + return false; + }; + if (shouldAckReaction() && messageId) { + reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { + logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); + }); + } + + const replyTarget = ctxPayload.To; + if (!replyTarget) { + runtime.error?.("matrix: missing reply target"); + return; + } + + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + + let didSendReply = false; + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) + .responsePrefix, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + await deliverMatrixReplies({ + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + }); + didSendReply = true; + }, + onError: (err, info) => { + runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: () => + sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), + onIdle: () => + sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), + }); + + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + }, + }); + markDispatchIdle(); + if (!queuedFinal) return; + didSendReply = true; + const finalCount = counts.final; + logVerboseMessage( + `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, + ); + if (didSendReply) { + const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160); + core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, { + sessionKey: route.sessionKey, + contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, + }); + } + } catch (err) { + runtime.error?.(`matrix handler failed: ${String(err)}`); + } + }; +} diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 36a964def9..35e75c4ed5 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -1,19 +1,8 @@ -import type { - EncryptedFile, - 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"; import type { CoreConfig, ReplyToMode } from "../../types.js"; @@ -24,146 +13,14 @@ import { resolveSharedMatrixClient, stopSharedClient, } from "../client.js"; -import { - formatPollAsText, - isPollStartType, - type PollStartContent, - parsePollStartContent, -} from "../poll-types.js"; -import { - reactMatrixMessage, - sendMessageMatrix, - sendReadReceiptMatrix, - sendTypingMatrix, -} from "../send.js"; -import { - resolveMatrixAllowListMatch, - resolveMatrixAllowListMatches, - normalizeAllowListLower, -} from "./allowlist.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; import { createDirectRoomTracker } from "./direct.js"; -import { downloadMatrixMedia } from "./media.js"; -import { resolveMentions } from "./mentions.js"; -import { deliverMatrixReplies } from "./replies.js"; -import { resolveMatrixRoomConfig } from "./rooms.js"; -import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { createMatrixRoomInfoResolver } from "./room-info.js"; 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; - file?: EncryptedFile; - 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; @@ -343,609 +200,40 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi const warnedEncryptedRooms = new Set(); const warnedCryptoMissingRooms = new Set(); - 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 { - const eventType = event.type; - if (eventType === EventType.RoomMessageEncrypted) { - // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled - return; - } - - const isPollEvent = isPollStartType(eventType); - const locationContent = event.content as LocationMessageEventContent; - const isLocationEvent = - eventType === EventType.Location || - (eventType === EventType.RoomMessage && - locationContent.msgtype === EventType.Location); - if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return; - logVerboseMessage( - `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, - ); - if (event.unsigned?.redacted_because) return; - const senderId = event.sender; - if (!senderId) return; - const selfUserId = await client.getUserId(); - if (senderId === selfUserId) return; - const eventTs = event.origin_server_ts; - const eventAge = event.unsigned?.age; - if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { - return; - } - if ( - typeof eventTs !== "number" && - typeof eventAge === "number" && - eventAge > startupGraceMs - ) { - return; - } - - const roomInfo = await getRoomInfo(roomId); - const roomName = roomInfo.name; - const roomAliases = [ - roomInfo.canonicalAlias ?? "", - ...roomInfo.altAliases, - ].filter(Boolean); - - let content = event.content as RoomMessageEventContent; - if (isPollEvent) { - const pollStartContent = event.content as PollStartContent; - const pollSummary = parsePollStartContent(pollStartContent); - if (pollSummary) { - pollSummary.eventId = event.event_id ?? ""; - pollSummary.roomId = roomId; - pollSummary.sender = senderId; - const senderDisplayName = await getMemberDisplayName(roomId, senderId); - pollSummary.senderName = senderDisplayName; - const pollText = formatPollAsText(pollSummary); - content = { - msgtype: "m.text", - body: pollText, - } as unknown as RoomMessageEventContent; - } else { - return; - } - } - - const locationPayload = resolveMatrixLocation({ - eventType, - content: content as LocationMessageEventContent, - }); - - const relates = content["m.relates_to"]; - if (relates && "rel_type" in relates) { - if (relates.rel_type === RelationType.Replace) return; - } - - const isDirectMessage = await directTracker.isDirectMessage({ - roomId, - senderId, - selfUserId, - }); - const isRoom = !isDirectMessage; - - if (isRoom && groupPolicy === "disabled") return; - - const roomConfigInfo = isRoom - ? resolveMatrixRoomConfig({ - rooms: roomsConfig, - roomId, - aliases: roomAliases, - name: roomName, - }) - : undefined; - const roomConfig = roomConfigInfo?.config; - const roomMatchMeta = roomConfigInfo - ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ - roomConfigInfo.matchSource ?? "none" - }` - : "matchKey=none matchSource=none"; - - if (isRoom && roomConfig && !roomConfigInfo?.allowed) { - logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); - return; - } - if (isRoom && groupPolicy === "allowlist") { - if (!roomConfigInfo?.allowlistConfigured) { - logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); - return; - } - if (!roomConfig) { - logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); - return; - } - } - - const senderName = await getMemberDisplayName(roomId, senderId); - const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []); - const effectiveAllowFrom = 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; - if (dmPolicy !== "open") { - const allowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveAllowFrom, - userId: senderId, - userName: senderName, - }); - const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); - if (!allowMatch.allowed) { - if (dmPolicy === "pairing") { - const { code, created } = await core.channel.pairing.upsertPairingRequest({ - channel: "matrix", - id: senderId, - meta: { name: senderName }, - }); - if (created) { - logVerboseMessage( - `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, - ); - try { - await sendMessageMatrix( - `room:${roomId}`, - [ - "Clawdbot: access not configured.", - "", - `Pairing code: ${code}`, - "", - "Ask the bot owner to approve with:", - "clawdbot pairing approve matrix ", - ].join("\n"), - { client }, - ); - } catch (err) { - logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); - } - } - } - if (dmPolicy !== "pairing") { - logVerboseMessage( - `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, - ); - } - return; - } - } - } - - const roomUsers = roomConfig?.users ?? []; - if (isRoom && roomUsers.length > 0) { - const userMatch = resolveMatrixAllowListMatch({ - allowList: normalizeAllowListLower(roomUsers), - userId: senderId, - userName: senderName, - }); - if (!userMatch.allowed) { - logVerboseMessage( - `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( - userMatch, - )})`, - ); - return; - } - } - if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { - const groupAllowMatch = resolveMatrixAllowListMatch({ - allowList: effectiveGroupAllowFrom, - userId: senderId, - 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 = locationPayload?.text - ?? (typeof content.body === "string" ? content.body.trim() : ""); - let media: { - path: string; - contentType?: string; - placeholder: string; - } | null = null; - const contentUrl = - "url" in content && typeof content.url === "string" ? content.url : undefined; - const contentFile = - "file" in content && content.file && typeof content.file === "object" - ? (content.file as EncryptedFile) - : undefined; - const mediaUrl = contentUrl ?? contentFile?.url; - if (!rawBody && !mediaUrl) { - return; - } - - const contentType = - "info" in content && content.info && "mimetype" in content.info - ? (content.info as { mimetype?: string }).mimetype - : undefined; - if (mediaUrl?.startsWith("mxc://")) { - try { - media = await downloadMatrixMedia({ - client, - mxcUrl: mediaUrl, - contentType, - maxBytes: mediaMaxBytes, - file: contentFile, - }); - } catch (err) { - logVerboseMessage(`matrix: media download failed: ${String(err)}`); - } - } - - const bodyText = rawBody || media?.placeholder || ""; - if (!bodyText) return; - - const { wasMentioned, hasExplicitMention } = resolveMentions({ - content, - userId: selfUserId, - text: bodyText, - mentionRegexes, - }); - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ - cfg, - surface: "matrix", - }); - const useAccessGroups = cfg.commands?.useAccessGroups !== false; - const senderAllowedForCommands = resolveMatrixAllowListMatches({ - allowList: effectiveAllowFrom, - userId: senderId, - 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 ( - isRoom && - allowTextCommands && - core.channel.text.hasControlCommand(bodyText, cfg) && - !commandAuthorized - ) { - logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`); - return; - } - const shouldRequireMention = isRoom - ? roomConfig?.autoReply === true - ? false - : roomConfig?.autoReply === false - ? true - : typeof roomConfig?.requireMention === "boolean" - ? roomConfig?.requireMention - : true - : false; - const shouldBypassMention = - allowTextCommands && - isRoom && - shouldRequireMention && - !wasMentioned && - !hasExplicitMention && - commandAuthorized && - core.channel.text.hasControlCommand(bodyText); - if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { - logger.info({ roomId, reason: "no-mention" }, "skipping room message"); - return; - } - - const messageId = event.event_id ?? ""; - const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; - const threadRootId = resolveMatrixThreadRootId({ event, content }); - const threadTarget = resolveMatrixThreadTarget({ - threadReplies, - messageId, - threadRootId, - isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available - }); - - const route = core.channel.routing.resolveAgentRoute({ - cfg, - channel: "matrix", - peer: { - kind: isDirectMessage ? "dm" : "channel", - id: isDirectMessage ? senderId : roomId, - }, - }); - const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); - const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; - const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { - agentId: route.agentId, - }); - const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); - const previousTimestamp = core.channel.session.readSessionUpdatedAt({ - storePath, - sessionKey: route.sessionKey, - }); - const body = core.channel.reply.formatAgentEnvelope({ - channel: "Matrix", - from: envelopeFrom, - timestamp: eventTs ?? undefined, - previousTimestamp, - envelope: envelopeOptions, - body: textWithId, - }); - - const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; - const ctxPayload = core.channel.reply.finalizeInboundContext({ - Body: body, - RawBody: bodyText, - CommandBody: bodyText, - From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, - To: `room:${roomId}`, - SessionKey: route.sessionKey, - AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "channel", - ConversationLabel: envelopeFrom, - SenderName: senderName, - SenderId: senderId, - SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), - GroupSubject: isRoom ? (roomName ?? roomId) : undefined, - GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, - GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, - Provider: "matrix" as const, - Surface: "matrix" as const, - WasMentioned: isRoom ? wasMentioned : undefined, - MessageSid: messageId, - ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), - MessageThreadId: threadTarget, - Timestamp: eventTs ?? undefined, - MediaPath: media?.path, - MediaType: media?.contentType, - MediaUrl: media?.path, - ...(locationPayload?.context ?? {}), - CommandAuthorized: commandAuthorized, - CommandSource: "text" as const, - OriginatingChannel: "matrix" as const, - OriginatingTo: `room:${roomId}`, - }); - - void core.channel.session.recordSessionMetaFromInbound({ - storePath, - sessionKey: ctxPayload.SessionKey ?? route.sessionKey, - ctx: ctxPayload, - }).catch((err) => { - logger.warn( - { error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey }, - "failed updating session meta", - ); - }); - - if (isDirectMessage) { - await core.channel.session.updateLastRoute({ - storePath, - sessionKey: route.mainSessionKey, - channel: "matrix", - to: `room:${roomId}`, - accountId: route.accountId, - ctx: ctxPayload, - }); - } - - const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); - logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); - - const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); - const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - const shouldAckReaction = () => { - if (!ackReaction) return false; - if (ackScope === "all") return true; - if (ackScope === "direct") return isDirectMessage; - if (ackScope === "group-all") return isRoom; - if (ackScope === "group-mentions") { - if (!isRoom) return false; - if (!shouldRequireMention) return false; - return wasMentioned || shouldBypassMention; - } - return false; - }; - if (shouldAckReaction() && messageId) { - reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { - logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); - }); - } - - const replyTarget = ctxPayload.To; - if (!replyTarget) { - runtime.error?.("matrix: missing reply target"); - return; - } - - if (messageId) { - sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { - logVerboseMessage( - `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, - ); - }); - } - - let didSendReply = false; - const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ - responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, - humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), - deliver: async (payload) => { - await deliverMatrixReplies({ - replies: [payload], - roomId, - client, - runtime, - textLimit, - replyToMode, - threadId: threadTarget, - }); - didSendReply = true; - }, - onError: (err, info) => { - runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); - }, - onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}), - onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}), - }); - - const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ - ctx: ctxPayload, - cfg, - dispatcher, - replyOptions: { - ...replyOptions, - skillFilter: roomConfig?.skills, - }, - }); - markDispatchIdle(); - if (!queuedFinal) return; - didSendReply = true; - const finalCount = counts.final; - logVerboseMessage( - `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, - ); - if (didSendReply) { - const preview = bodyText.replace(/\s+/g, " ").slice(0, 160); - core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, { - sessionKey: route.sessionKey, - contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, - }); - } - } catch (err) { - runtime.error?.(`matrix handler failed: ${String(err)}`); - } - }; - - // matrix-bot-sdk uses on("room.message", handler) - client.on("room.message", handleRoomMessage); - - 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}`); + const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); + const handleRoomMessage = createMatrixRoomMessageHandler({ + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, }); - 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 hint = core.system.formatNativeDependencyHint({ - packageName: "@matrix-org/matrix-sdk-crypto-nodejs", - manager: "pnpm", - downloadCommand: - "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", - }); - const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; - logger.warn({ roomId }, warning); - } - return; - } - if (eventType === EventType.RoomMember) { - const membership = (event?.content as { membership?: string } | undefined)?.membership; - const stateKey = (event as { state_key?: string }).state_key ?? ""; - logVerboseMessage( - `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, - ); - } + registerMatrixMonitorEvents({ + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint: core.system.formatNativeDependencyHint, + onRoomMessage: handleRoomMessage, }); logVerboseMessage("matrix: starting client"); diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts new file mode 100644 index 0000000000..22374cad87 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -0,0 +1,83 @@ +import type { LocationMessageEventContent } from "matrix-bot-sdk"; + +import { + formatLocationText, + toLocationContext, + type NormalizedLocation, +} from "clawdbot/plugin-sdk"; +import { EventType } from "./types.js"; + +export type MatrixLocationPayload = { + text: string; + context: ReturnType; +}; + +type GeoUriParams = { + latitude: number; + longitude: number; + accuracy?: number; +}; + +function parseGeoUri(value: string): GeoUriParams | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!trimmed.toLowerCase().startsWith("geo:")) return null; + const payload = trimmed.slice(4); + const [coordsPart, ...paramParts] = payload.split(";"); + const coords = coordsPart.split(","); + if (coords.length < 2) return null; + const latitude = Number.parseFloat(coords[0] ?? ""); + const longitude = Number.parseFloat(coords[1] ?? ""); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + + const params = new Map(); + for (const part of paramParts) { + const segment = part.trim(); + if (!segment) continue; + const eqIndex = segment.indexOf("="); + const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex); + const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1); + const key = rawKey.trim().toLowerCase(); + if (!key) continue; + const valuePart = rawValue.trim(); + params.set(key, valuePart ? decodeURIComponent(valuePart) : ""); + } + + const accuracyRaw = params.get("u"); + const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined; + + return { + latitude, + longitude, + accuracy: Number.isFinite(accuracy) ? accuracy : undefined, + }; +} + +export function resolveMatrixLocation(params: { + eventType: string; + content: LocationMessageEventContent; +}): MatrixLocationPayload | null { + const { eventType, content } = params; + const isLocation = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && content.msgtype === EventType.Location); + if (!isLocation) return null; + const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : ""; + if (!geoUri) return null; + const parsed = parseGeoUri(geoUri); + if (!parsed) return null; + const caption = typeof content.body === "string" ? content.body.trim() : ""; + const location: NormalizedLocation = { + latitude: parsed.latitude, + longitude: parsed.longitude, + accuracy: parsed.accuracy, + caption: caption || undefined, + source: "pin", + isLive: false, + }; + + return { + text: formatLocationText(location), + context: toLocationContext(location), + }; +} diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts new file mode 100644 index 0000000000..e32b5b37a3 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -0,0 +1,58 @@ +import type { MatrixClient } from "matrix-bot-sdk"; + +export type MatrixRoomInfo = { + name?: string; + canonicalAlias?: string; + altAliases: string[]; +}; + +export function createMatrixRoomInfoResolver(client: MatrixClient) { + const roomInfoCache = new Map(); + + const getRoomInfo = async (roomId: string): Promise => { + const cached = roomInfoCache.get(roomId); + if (cached) return cached; + let name: string | undefined; + let canonicalAlias: string | undefined; + let altAliases: string[] = []; + try { + const nameState = await client + .getRoomStateEvent(roomId, "m.room.name", "") + .catch(() => null); + name = nameState?.name; + } catch { + // ignore + } + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + canonicalAlias = aliasState?.alias; + altAliases = aliasState?.alt_aliases ?? []; + } catch { + // ignore + } + const info = { name, canonicalAlias, altAliases }; + roomInfoCache.set(roomId, info); + return info; + }; + + const getMemberDisplayName = async ( + roomId: string, + userId: string, + ): Promise => { + try { + const memberState = await client + .getRoomStateEvent(roomId, "m.room.member", userId) + .catch(() => null); + return memberState?.displayname ?? userId; + } catch { + return userId; + } + }; + + return { + getRoomInfo, + getMemberDisplayName, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts new file mode 100644 index 0000000000..d77bdac673 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -0,0 +1,38 @@ +import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk"; + +export const EventType = { + RoomMessage: "m.room.message", + RoomMessageEncrypted: "m.room.encrypted", + RoomMember: "m.room.member", + Location: "m.location", +} as const; + +export const RelationType = { + Replace: "m.replace", + Thread: "m.thread", +} as const; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; +}; + +export type RoomMessageEventContent = MessageEventContent & { + url?: string; + file?: EncryptedFile; + info?: { + mimetype?: string; + }; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index dfc3585159..634871123a 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,504 +1,38 @@ -import type { - DimensionalFileInfo, - EncryptedFile, - FileWithThumbnailInfo, - MessageEventContent, - TextualMessageEventContent, - TimedFileInfo, - VideoFileInfo, - MatrixClient, -} from "matrix-bot-sdk"; -import { parseBuffer, type IFileInfo } from "music-metadata"; +import type { MatrixClient } from "matrix-bot-sdk"; import type { PollInput } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; -import { getActiveMatrixClient } from "./active-client.js"; -import { - createMatrixClient, - isBunRuntime, - resolveMatrixAuth, - resolveSharedMatrixClient, -} from "./client.js"; -import { markdownToMatrixHtml } from "./format.js"; import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; -import type { CoreConfig } from "../types.js"; +import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; +import { + buildReplyRelation, + buildTextContent, + buildThreadRelation, + resolveMatrixMsgType, + resolveMatrixVoiceDecision, +} from "./send/formatting.js"; +import { + buildMediaContent, + prepareImageInfo, + resolveMediaDurationMs, + uploadMediaMaybeEncrypted, +} from "./send/media.js"; +import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; +import { + EventType, + MsgType, + RelationType, + type MatrixOutboundContent, + type MatrixSendOpts, + type MatrixSendResult, + type ReactionEventContent, +} from "./send/types.js"; const MATRIX_TEXT_LIMIT = 4000; const getCore = () => getMatrixRuntime(); -// 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 MatrixThreadRelation = { - rel_type: typeof RelationType.Thread; - event_id: string; - is_falling_back?: boolean; - "m.in_reply_to"?: { event_id: string }; -}; - -type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation; - -type MatrixReplyMeta = { - "m.relates_to"?: MatrixRelation; -}; - -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; - roomId: string; -}; - -export type MatrixSendOpts = { - client?: MatrixClient; - mediaUrl?: string; - replyToId?: string; - threadId?: string | number | null; - timeoutMs?: number; - /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ - audioAsVoice?: boolean; -}; - -function ensureNodeRuntime() { - if (isBunRuntime()) { - throw new Error("Matrix support requires Node (bun runtime not supported)"); - } -} - -function resolveMediaMaxBytes(): number | undefined { - const cfg = getCore().config.loadConfig() as CoreConfig; - if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { - return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; - } - return undefined; -} - -function normalizeTarget(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error("Matrix target is required (room: or #alias)"); - } - return trimmed; -} - -function normalizeThreadId(raw?: string | number | null): string | null { - if (raw === undefined || raw === null) return null; - const trimmed = String(raw).trim(); - return trimmed ? trimmed : null; -} - -async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { - const trimmed = userId.trim(); - if (!trimmed.startsWith("@")) { - throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); - } - // 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.`, - ); -} - -export async function resolveMatrixRoomId( - client: MatrixClient, - raw: string, -): Promise { - const target = normalizeTarget(raw); - const lowered = target.toLowerCase(); - if (lowered.startsWith("matrix:")) { - return await resolveMatrixRoomId(client, target.slice("matrix:".length)); - } - if (lowered.startsWith("room:")) { - return await resolveMatrixRoomId(client, target.slice("room:".length)); - } - if (lowered.startsWith("channel:")) { - return await resolveMatrixRoomId(client, target.slice("channel:".length)); - } - if (lowered.startsWith("user:")) { - return await resolveDirectRoomId(client, target.slice("user:".length)); - } - if (target.startsWith("@")) { - return await resolveDirectRoomId(client, target); - } - if (target.startsWith("#")) { - const resolved = await client.resolveRoom(target); - if (!resolved) { - throw new Error(`Matrix alias ${target} could not be resolved`); - } - return resolved; - } - return target; -} - -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: MatrixMediaMsgType; - body: string; - url?: string; - filename?: string; - mimetype?: string; - size: number; - relation?: MatrixRelation; - isVoice?: boolean; - durationMs?: number; - 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: info ?? undefined, - }; - // Encrypted media should only include the "file" payload, not top-level "url". - if (!params.file && params.url) { - base.url = params.url; - } - // For encrypted files, add the file object - if (params.file) { - base.file = params.file; - } - if (params.isVoice) { - base["org.matrix.msc3245.voice"] = {}; - if (typeof params.durationMs === "number") { - base["org.matrix.msc1767.audio"] = { - duration: params.durationMs, - }; - } - } - if (params.relation) { - base["m.relates_to"] = params.relation; - } - applyMatrixFormatting(base, params.body); - return base; -} - -function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent { - const content: MatrixTextContent = relation - ? { - msgtype: MsgType.Text, - body, - "m.relates_to": relation, - } - : { - msgtype: MsgType.Text, - body, - }; - applyMatrixFormatting(content, body); - return content; -} - -function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { - const formatted = markdownToMatrixHtml(body ?? ""); - if (!formatted) return; - content.format = "org.matrix.custom.html"; - content.formatted_body = formatted; -} - -function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { - const trimmed = replyToId?.trim(); - if (!trimmed) return undefined; - return { "m.in_reply_to": { event_id: trimmed } }; -} - -function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation { - const trimmed = threadId.trim(); - return { - rel_type: RelationType.Thread, - event_id: trimmed, - is_falling_back: true, - "m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) }, - }; -} - -function resolveMatrixMsgType( - contentType?: string, - fileName?: string, -): MatrixMediaMsgType { - const kind = getCore().media.mediaKindFromMime(contentType ?? ""); - switch (kind) { - case "image": - return MsgType.Image; - case "audio": - return MsgType.Audio; - case "video": - return MsgType.Video; - default: - return MsgType.File; - } -} - -function resolveMatrixVoiceDecision(opts: { - wantsVoice: boolean; - contentType?: string; - fileName?: string; -}): { useVoice: boolean } { - if (!opts.wantsVoice) return { useVoice: false }; - if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) { - return { useVoice: true }; - } - return { useVoice: false }; -} - -const THUMBNAIL_MAX_SIDE = 800; -const THUMBNAIL_QUALITY = 80; - -async function prepareImageInfo(params: { - buffer: Buffer; - client: MatrixClient; -}): Promise { - const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null); - if (!meta) return undefined; - const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; - const maxDim = Math.max(meta.width, meta.height); - if (maxDim > THUMBNAIL_MAX_SIDE) { - try { - const thumbBuffer = await getCore().media.resizeToJpeg({ - buffer: params.buffer, - maxSide: THUMBNAIL_MAX_SIDE, - quality: THUMBNAIL_QUALITY, - withoutEnlargement: true, - }); - const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null); - const thumbUri = await params.client.uploadContent( - thumbBuffer, - "image/jpeg", - "thumbnail.jpg", - ); - imageInfo.thumbnail_url = thumbUri; - if (thumbMeta) { - imageInfo.thumbnail_info = { - w: thumbMeta.width, - h: thumbMeta.height, - mimetype: "image/jpeg", - size: thumbBuffer.byteLength, - }; - } - } catch { - // Thumbnail generation failed, continue without it - } - } - return imageInfo; -} - -async function resolveMediaDurationMs(params: { - buffer: Buffer; - contentType?: string; - fileName?: string; - kind: MediaKind; -}): Promise { - if (params.kind !== "audio" && params.kind !== "video") return undefined; - try { - const fileInfo: IFileInfo | string | undefined = - params.contentType || params.fileName - ? { - mimeType: params.contentType, - size: params.buffer.byteLength, - path: params.fileName, - } - : undefined; - const metadata = await parseBuffer(params.buffer, fileInfo, { - duration: true, - skipCovers: true, - }); - const durationSeconds = metadata.format.duration; - if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) { - return Math.max(0, Math.round(durationSeconds * 1000)); - } - } catch { - // Duration is optional; ignore parse failures. - } - return undefined; -} - -async function uploadFile( - client: MatrixClient, - file: Buffer, - params: { - contentType?: string; - filename?: string; - }, -): Promise { - return await client.uploadContent(file, params.contentType, params.filename); -} - -/** - * Upload media with optional encryption for E2EE rooms. - */ -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: { - client?: MatrixClient; - timeoutMs?: number; -}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { - ensureNodeRuntime(); - if (opts.client) return { client: opts.client, stopOnDone: false }; - const active = getActiveMatrixClient(); - if (active) return { client: active, stopOnDone: false }; - const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT); - if (shouldShareClient) { - const client = await resolveSharedMatrixClient({ - timeoutMs: opts.timeoutMs, - }); - return { client, stopOnDone: false }; - } - const auth = await resolveMatrixAuth(); - const client = await createMatrixClient({ - homeserver: auth.homeserver, - userId: auth.userId, - accessToken: auth.accessToken, - encryption: auth.encryption, - localTimeoutMs: opts.timeoutMs, - }); - if (auth.encryption && client.crypto) { - try { - const joinedRooms = await client.getJoinedRooms(); - await client.crypto.prepare(joinedRooms); - } catch { - // Ignore crypto prep failures for one-off sends; normal sync will retry. - } - } - // matrix-bot-sdk uses start() instead of startClient() - await client.start(); - return { client, stopOnDone: true }; -} +export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; +export { resolveMatrixRoomId } from "./send/targets.js"; export async function sendMessageMatrix( to: string, @@ -551,7 +85,9 @@ export async function sendMessageMatrix( }); const msgtype = useVoice ? MsgType.Audio : baseMsgType; const isImage = msgtype === MsgType.Image; - const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; const [firstChunk, ...rest] = chunks; const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); const content = buildMediaContent({ diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts new file mode 100644 index 0000000000..2faa190917 --- /dev/null +++ b/extensions/matrix/src/matrix/send/client.ts @@ -0,0 +1,63 @@ +import type { MatrixClient } from "matrix-bot-sdk"; + +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; +import type { CoreConfig } from "../types.js"; + +const getCore = () => getMatrixRuntime(); + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export function resolveMediaMaxBytes(): number | undefined { + const cfg = getCore().config.loadConfig() as CoreConfig; + if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { + return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; + } + return undefined; +} + +export async function resolveMatrixClient(opts: { + client?: MatrixClient; + timeoutMs?: number; +}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { + ensureNodeRuntime(); + if (opts.client) return { client: opts.client, stopOnDone: false }; + const active = getActiveMatrixClient(); + if (active) return { client: active, stopOnDone: false }; + const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth(); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off sends; normal sync will retry. + } + } + // matrix-bot-sdk uses start() instead of startClient() + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts new file mode 100644 index 0000000000..ef2edeaf41 --- /dev/null +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -0,0 +1,92 @@ +import { markdownToMatrixHtml } from "../format.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { + MsgType, + RelationType, + type MatrixFormattedContent, + type MatrixMediaMsgType, + type MatrixRelation, + type MatrixReplyRelation, + type MatrixTextContent, + type MatrixThreadRelation, +} from "./types.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildTextContent( + body: string, + relation?: MatrixRelation, +): MatrixTextContent { + const content: MatrixTextContent = relation + ? { + msgtype: MsgType.Text, + body, + "m.relates_to": relation, + } + : { + msgtype: MsgType.Text, + body, + }; + applyMatrixFormatting(content, body); + return content; +} + +export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { + const formatted = markdownToMatrixHtml(body ?? ""); + if (!formatted) return; + content.format = "org.matrix.custom.html"; + content.formatted_body = formatted; +} + +export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { + const trimmed = replyToId?.trim(); + if (!trimmed) return undefined; + return { "m.in_reply_to": { event_id: trimmed } }; +} + +export function buildThreadRelation( + threadId: string, + replyToId?: string, +): MatrixThreadRelation { + const trimmed = threadId.trim(); + return { + rel_type: RelationType.Thread, + event_id: trimmed, + is_falling_back: true, + "m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) }, + }; +} + +export function resolveMatrixMsgType( + contentType?: string, + _fileName?: string, +): MatrixMediaMsgType { + const kind = getCore().media.mediaKindFromMime(contentType ?? ""); + switch (kind) { + case "image": + return MsgType.Image; + case "audio": + return MsgType.Audio; + case "video": + return MsgType.Video; + default: + return MsgType.File; + } +} + +export function resolveMatrixVoiceDecision(opts: { + wantsVoice: boolean; + contentType?: string; + fileName?: string; +}): { useVoice: boolean } { + if (!opts.wantsVoice) return { useVoice: false }; + if ( + getCore().media.isVoiceCompatibleAudio({ + contentType: opts.contentType, + fileName: opts.fileName, + }) + ) { + return { useVoice: true }; + } + return { useVoice: false }; +} diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts new file mode 100644 index 0000000000..d4cf298050 --- /dev/null +++ b/extensions/matrix/src/matrix/send/media.ts @@ -0,0 +1,220 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MatrixClient, + TimedFileInfo, + VideoFileInfo, +} from "matrix-bot-sdk"; +import { parseBuffer, type IFileInfo } from "music-metadata"; + +import { getMatrixRuntime } from "../../runtime.js"; +import { + type MatrixMediaContent, + type MatrixMediaInfo, + type MatrixMediaMsgType, + type MatrixRelation, + type MediaKind, +} from "./types.js"; +import { applyMatrixFormatting } from "./formatting.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildMatrixMediaInfo(params: { + size: number; + mimetype?: string; + durationMs?: number; + imageInfo?: DimensionalFileInfo; +}): MatrixMediaInfo | undefined { + const base: FileWithThumbnailInfo = {}; + if (Number.isFinite(params.size)) { + base.size = params.size; + } + if (params.mimetype) { + base.mimetype = params.mimetype; + } + if (params.imageInfo) { + const dimensional: DimensionalFileInfo = { + ...base, + ...params.imageInfo, + }; + if (typeof params.durationMs === "number") { + const videoInfo: VideoFileInfo = { + ...dimensional, + duration: params.durationMs, + }; + return videoInfo; + } + return dimensional; + } + if (typeof params.durationMs === "number") { + const timedInfo: TimedFileInfo = { + ...base, + duration: params.durationMs, + }; + return timedInfo; + } + if (Object.keys(base).length === 0) return undefined; + return base; +} + +export function buildMediaContent(params: { + msgtype: MatrixMediaMsgType; + body: string; + url?: string; + filename?: string; + mimetype?: string; + size: number; + relation?: MatrixRelation; + isVoice?: boolean; + durationMs?: number; + imageInfo?: DimensionalFileInfo; + file?: EncryptedFile; +}): MatrixMediaContent { + const info = buildMatrixMediaInfo({ + size: params.size, + mimetype: params.mimetype, + durationMs: params.durationMs, + imageInfo: params.imageInfo, + }); + const base: MatrixMediaContent = { + msgtype: params.msgtype, + body: params.body, + filename: params.filename, + info: info ?? undefined, + }; + // Encrypted media should only include the "file" payload, not top-level "url". + if (!params.file && params.url) { + base.url = params.url; + } + // For encrypted files, add the file object + if (params.file) { + base.file = params.file; + } + if (params.isVoice) { + base["org.matrix.msc3245.voice"] = {}; + if (typeof params.durationMs === "number") { + base["org.matrix.msc1767.audio"] = { + duration: params.durationMs, + }; + } + } + if (params.relation) { + base["m.relates_to"] = params.relation; + } + applyMatrixFormatting(base, params.body); + return base; +} + +const THUMBNAIL_MAX_SIDE = 800; +const THUMBNAIL_QUALITY = 80; + +export async function prepareImageInfo(params: { + buffer: Buffer; + client: MatrixClient; +}): Promise { + const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null); + if (!meta) return undefined; + const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; + const maxDim = Math.max(meta.width, meta.height); + if (maxDim > THUMBNAIL_MAX_SIDE) { + try { + const thumbBuffer = await getCore().media.resizeToJpeg({ + buffer: params.buffer, + maxSide: THUMBNAIL_MAX_SIDE, + quality: THUMBNAIL_QUALITY, + withoutEnlargement: true, + }); + const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null); + const thumbUri = await params.client.uploadContent( + thumbBuffer, + "image/jpeg", + "thumbnail.jpg", + ); + imageInfo.thumbnail_url = thumbUri; + if (thumbMeta) { + imageInfo.thumbnail_info = { + w: thumbMeta.width, + h: thumbMeta.height, + mimetype: "image/jpeg", + size: thumbBuffer.byteLength, + }; + } + } catch { + // Thumbnail generation failed, continue without it + } + } + return imageInfo; +} + +export async function resolveMediaDurationMs(params: { + buffer: Buffer; + contentType?: string; + fileName?: string; + kind: MediaKind; +}): Promise { + if (params.kind !== "audio" && params.kind !== "video") return undefined; + try { + const fileInfo: IFileInfo | string | undefined = + params.contentType || params.fileName + ? { + mimeType: params.contentType, + size: params.buffer.byteLength, + path: params.fileName, + } + : undefined; + const metadata = await parseBuffer(params.buffer, fileInfo, { + duration: true, + skipCovers: true, + }); + const durationSeconds = metadata.format.duration; + if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) { + return Math.max(0, Math.round(durationSeconds * 1000)); + } + } catch { + // Duration is optional; ignore parse failures. + } + return undefined; +} + +async function uploadFile( + client: MatrixClient, + file: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise { + return await client.uploadContent(file, params.contentType, params.filename); +} + +/** + * Upload media with optional encryption for E2EE rooms. + */ +export async function uploadMediaMaybeEncrypted( + client: MatrixClient, + roomId: string, + buffer: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise<{ url: string; file?: EncryptedFile }> { + // Check if room is encrypted and crypto is available + const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId); + + if (isEncrypted && client.crypto) { + // Encrypt the media before uploading + const encrypted = await client.crypto.encryptMedia(buffer); + const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename); + const file: EncryptedFile = { url: mxc, ...encrypted.file }; + return { + url: mxc, + file, + }; + } + + // Upload unencrypted + const mxc = await uploadFile(client, buffer, params); + return { url: mxc }; +} diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts new file mode 100644 index 0000000000..7ab6477bad --- /dev/null +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -0,0 +1,66 @@ +import type { MatrixClient } from "matrix-bot-sdk"; + +import { EventType, type MatrixDirectAccountData } from "./types.js"; + +function normalizeTarget(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Matrix target is required (room: or #alias)"); + } + return trimmed; +} + +export function normalizeThreadId(raw?: string | number | null): string | null { + if (raw === undefined || raw === null) return null; + const trimmed = String(raw).trim(); + return trimmed ? trimmed : null; +} + +async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); + } + // 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.`, + ); +} + +export async function resolveMatrixRoomId( + client: MatrixClient, + raw: string, +): Promise { + const target = normalizeTarget(raw); + const lowered = target.toLowerCase(); + if (lowered.startsWith("matrix:")) { + return await resolveMatrixRoomId(client, target.slice("matrix:".length)); + } + if (lowered.startsWith("room:")) { + return await resolveMatrixRoomId(client, target.slice("room:".length)); + } + if (lowered.startsWith("channel:")) { + return await resolveMatrixRoomId(client, target.slice("channel:".length)); + } + if (lowered.startsWith("user:")) { + return await resolveDirectRoomId(client, target.slice("user:".length)); + } + if (target.startsWith("@")) { + return await resolveDirectRoomId(client, target); + } + if (target.startsWith("#")) { + const resolved = await client.resolveRoom(target); + if (!resolved) { + throw new Error(`Matrix alias ${target} could not be resolved`); + } + return resolved; + } + return target; +} diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts new file mode 100644 index 0000000000..51b1b10243 --- /dev/null +++ b/extensions/matrix/src/matrix/send/types.ts @@ -0,0 +1,108 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MessageEventContent, + TextualMessageEventContent, + TimedFileInfo, + VideoFileInfo, +} from "matrix-bot-sdk"; + +// Message types +export const MsgType = { + Text: "m.text", + Image: "m.image", + Audio: "m.audio", + Video: "m.video", + File: "m.file", + Notice: "m.notice", +} as const; + +// Relation types +export const RelationType = { + Annotation: "m.annotation", + Replace: "m.replace", + Thread: "m.thread", +} as const; + +// Event types +export const EventType = { + Direct: "m.direct", + Reaction: "m.reaction", + RoomMessage: "m.room.message", +} as const; + +export type MatrixDirectAccountData = Record; + +export type MatrixReplyRelation = { + "m.in_reply_to": { event_id: string }; +}; + +export type MatrixThreadRelation = { + rel_type: typeof RelationType.Thread; + event_id: string; + is_falling_back?: boolean; + "m.in_reply_to"?: { event_id: string }; +}; + +export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation; + +export type MatrixReplyMeta = { + "m.relates_to"?: MatrixRelation; +}; + +export type MatrixMediaInfo = + | FileWithThumbnailInfo + | DimensionalFileInfo + | TimedFileInfo + | VideoFileInfo; + +export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta; + +export type MatrixMediaContent = MessageEventContent & + MatrixReplyMeta & { + info?: MatrixMediaInfo; + url?: string; + file?: EncryptedFile; + filename?: string; + "org.matrix.msc3245.voice"?: Record; + "org.matrix.msc1767.audio"?: { duration: number }; + }; + +export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: typeof RelationType.Annotation; + event_id: string; + key: string; + }; +}; + +export type MatrixSendResult = { + messageId: string; + roomId: string; +}; + +export type MatrixSendOpts = { + client?: import("matrix-bot-sdk").MatrixClient; + mediaUrl?: string; + replyToId?: string; + threadId?: string | number | null; + timeoutMs?: number; + /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ + audioAsVoice?: boolean; +}; + +export type MatrixMediaMsgType = + | typeof MsgType.Image + | typeof MsgType.Audio + | typeof MsgType.Video + | typeof MsgType.File; + +export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +export type MatrixFormattedContent = MessageEventContent & { + format?: string; + formatted_body?: string; +};