rewrite(matrix): use matrix-bot-sdk as base to enable e2ee encryption, strictly follow location + typing + group concepts, fix room bugs

This commit is contained in:
Sebastian Schubotz
2026-01-20 09:37:27 +01:00
committed by Peter Steinberger
parent dd82d32d85
commit 9b71382efb
32 changed files with 1727 additions and 616 deletions

View File

@@ -1,4 +1,3 @@
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
@@ -11,7 +10,7 @@ describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime({
state: {
resolveStateDir: () => os.tmpdir(),
resolveStateDir: (_env, homeDir) => homeDir(),
},
} as PluginRuntime);
});
@@ -21,7 +20,8 @@ describe("matrix directory", () => {
channels: {
matrix: {
dm: { allowFrom: ["matrix:@alice:example.org", "bob"] },
rooms: {
groupAllowFrom: ["@dana:example.org"],
groups: {
"!room1:example.org": { users: ["@carol:example.org"] },
"#alias:example.org": { users: [] },
},
@@ -40,6 +40,7 @@ describe("matrix directory", () => {
{ kind: "user", id: "user:@alice:example.org" },
{ kind: "user", id: "bob", name: "incomplete id; expected @user:server" },
{ kind: "user", id: "user:@carol:example.org" },
{ kind: "user", id: "user:@dana:example.org" },
]),
);

View File

@@ -46,10 +46,12 @@ const meta = {
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim();
if (!normalized) return undefined;
if (normalized.toLowerCase().startsWith("matrix:")) {
const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim();
}
return normalized ? normalized.toLowerCase() : undefined;
const stripped = normalized.replace(/^(room|channel|user):/i, "").trim();
return stripped || undefined;
}
function buildMatrixConfigUpdate(
@@ -155,10 +157,11 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}),
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy =
account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.rooms to restrict rooms.",
"- Matrix rooms: groupPolicy=\"open\" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy=\"allowlist\" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.",
];
},
},
@@ -168,6 +171,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
threading: {
resolveReplyToMode: ({ cfg }) =>
(cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const currentTarget = context.To;
return {
currentChannelId: currentTarget?.trim() || undefined,
currentThreadTs:
context.MessageThreadId != null
? String(context.MessageThreadId)
: context.ReplyToId,
hasRepliedRef,
};
},
},
messaging: {
normalizeTarget: normalizeMatrixMessagingTarget,
@@ -194,7 +208,14 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
ids.add(raw.replace(/^matrix:/i, ""));
}
for (const room of Object.values(account.config.rooms ?? {})) {
for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
ids.add(raw.replace(/^matrix:/i, ""));
}
const groups = account.config.groups ?? account.config.rooms ?? {};
for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) {
const raw = String(entry).trim();
if (!raw || raw === "*") continue;
@@ -226,7 +247,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const ids = Object.keys(account.config.rooms ?? {})
const groups = account.config.groups ?? account.config.rooms ?? {};
const ids = Object.keys(groups)
.map((raw) => raw.trim())
.filter((raw) => Boolean(raw) && raw !== "*")
.map((raw) => raw.replace(/^matrix:/i, ""))
@@ -263,10 +285,16 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
validateInput: ({ input }) => {
if (input.useEnv) return null;
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
if (!input.userId?.trim()) return "Matrix requires --user-id";
if (!input.accessToken?.trim() && !input.password?.trim()) {
const accessToken = input.accessToken?.trim();
const password = input.password?.trim();
const userId = input.userId?.trim();
if (!accessToken && !password) {
return "Matrix requires --access-token or --password";
}
if (!accessToken) {
if (!userId) return "Matrix requires --user-id when using --password";
if (!password) return "Matrix requires --password when using --user-id";
}
return null;
},
applyAccountConfig: ({ cfg, input }) => {

View File

@@ -41,6 +41,7 @@ export const MatrixConfigSchema = z.object({
password: z.string().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),
encryption: z.boolean().optional(),
allowlistOnly: z.boolean().optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
@@ -49,7 +50,9 @@ export const MatrixConfigSchema = z.object({
mediaMaxMb: z.number().optional(),
autoJoin: z.enum(["always", "allowlist", "off"]).optional(),
autoJoinAllowlist: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
dm: matrixDmSchema,
groups: z.object({}).catchall(matrixRoomSchema).optional(),
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
actions: matrixActionSchema,
});

View File

@@ -20,7 +20,7 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
const aliases = groupChannel ? [groupChannel] : [];
const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
roomId,
aliases,
name: groupChannel || undefined,

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixAccount } from "./accounts.js";
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));
const envKeys = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
"MATRIX_ACCESS_TOKEN",
"MATRIX_PASSWORD",
"MATRIX_DEVICE_NAME",
];
describe("resolveMatrixAccount", () => {
let prevEnv: Record<string, string | undefined> = {};
beforeEach(() => {
prevEnv = {};
for (const key of envKeys) {
prevEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of envKeys) {
const value = prevEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("treats access-token-only config as configured", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-access",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
it("requires userId + password when no access token is set", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(false);
});
it("marks password auth as configured when userId is present", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const account = resolveMatrixAccount({ cfg });
expect(account.configured).toBe(true);
});
});

View File

@@ -31,18 +31,20 @@ export function resolveMatrixAccount(params: {
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env);
const hasCore = Boolean(resolved.homeserver && resolved.userId);
const hasToken = Boolean(resolved.accessToken || resolved.password);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env);
const hasStored =
stored &&
resolved.homeserver &&
resolved.userId &&
credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
});
const configured = hasCore && (hasToken || Boolean(hasStored));
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
return {
accountId,
enabled,

View File

@@ -1,19 +1,4 @@
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
import {
Direction,
EventType,
MatrixError,
MsgType,
RelationType,
} from "matrix-js-sdk";
import type {
ReactionEventContent,
RoomMessageEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import type {
RoomPinnedEventsEventContent,
RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js";
import type { MatrixClient } from "matrix-bot-sdk";
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
@@ -23,7 +8,6 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
waitForMatrixSync,
} from "./client.js";
import {
reactMatrixMessage,
@@ -31,6 +15,62 @@ import {
sendMessageMatrix,
} from "./send.js";
// Constants that were previously from matrix-js-sdk
const MsgType = {
Text: "m.text",
} as const;
const RelationType = {
Replace: "m.replace",
Annotation: "m.annotation",
} as const;
const EventType = {
RoomMessage: "m.room.message",
RoomPinnedEvents: "m.room.pinned_events",
RoomTopic: "m.room.topic",
Reaction: "m.reaction",
} as const;
// Type definitions for matrix-bot-sdk event content
type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.new_content"?: RoomMessageEventContent;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
type ReactionEventContent = {
"m.relates_to": {
rel_type: string;
event_id: string;
key: string;
};
};
type RoomPinnedEventsEventContent = {
pinned: string[];
};
type RoomTopicEventContent = {
topic?: string;
};
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
redacted_because?: unknown;
};
};
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
@@ -86,19 +126,15 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
await client.startClient({
initialSyncLimit: 0,
lazyLoadMembers: true,
threadSupport: true,
});
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
await client.start();
return { client, stopOnDone: true };
}
function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
const content = event.getContent<RoomMessageEventContent>();
function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary {
const content = event.content as RoomMessageEventContent;
const relates = content["m.relates_to"];
let relType: string | undefined;
let eventId: string | undefined;
@@ -118,27 +154,28 @@ function summarizeMatrixEvent(event: MatrixEvent): MatrixMessageSummary {
}
: undefined;
return {
eventId: event.getId() ?? undefined,
sender: event.getSender() ?? undefined,
eventId: event.event_id,
sender: event.sender,
body: content.body,
msgtype: content.msgtype,
timestamp: event.getTs() ?? undefined,
timestamp: event.origin_server_ts,
relatesTo,
};
}
async function readPinnedEvents(client: MatrixClient, roomId: string): Promise<string[]> {
try {
const content = (await client.getStateEvent(
const content = (await client.getRoomStateEvent(
roomId,
EventType.RoomPinnedEvents,
"",
)) as RoomPinnedEventsEventContent;
const pinned = content.pinned;
return pinned.filter((id) => id.trim().length > 0);
} catch (err) {
const httpStatus = err instanceof MatrixError ? err.httpStatus : undefined;
const errcode = err instanceof MatrixError ? err.errcode : undefined;
} catch (err: unknown) {
const errObj = err as { statusCode?: number; body?: { errcode?: string } };
const httpStatus = errObj.statusCode;
const errcode = errObj.body?.errcode;
if (httpStatus === 404 || errcode === "M_NOT_FOUND") {
return [];
}
@@ -151,11 +188,14 @@ async function fetchEventSummary(
roomId: string,
eventId: string,
): Promise<MatrixMessageSummary | null> {
const raw = await client.fetchRoomEvent(roomId, eventId);
const mapper = client.getEventMapper();
const event = mapper(raw);
if (event.isRedacted()) return null;
return summarizeMatrixEvent(event);
try {
const raw = await client.getEvent(roomId, eventId) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null;
return summarizeMatrixRawEvent(raw);
} catch (err) {
// Event not found, redacted, or inaccessible - return null
return null;
}
}
export async function sendMatrixMessage(
@@ -200,10 +240,10 @@ export async function editMatrixMessage(
event_id: messageId,
},
};
const response = await client.sendMessage(resolvedRoom, payload);
return { eventId: response.event_id ?? null };
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -215,11 +255,9 @@ export async function deleteMatrixMessage(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, undefined, {
reason: opts.reason,
});
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -242,22 +280,25 @@ export async function readMatrixMessages(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 20;
const token = opts.before?.trim() || opts.after?.trim() || null;
const dir = opts.after ? Direction.Forward : Direction.Backward;
const res = await client.createMessagesRequest(resolvedRoom, token, limit, dir);
const mapper = client.getEventMapper();
const events = res.chunk.map(mapper);
const messages = events
.filter((event) => event.getType() === EventType.RoomMessage)
.filter((event) => !event.isRedacted())
.map(summarizeMatrixEvent);
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest("GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, {
dir,
limit,
from: token,
}) as { chunk: MatrixRawEvent[]; start?: string; end?: string };
const messages = res.chunk
.filter((event) => event.type === EventType.RoomMessage)
.filter((event) => !event.unsigned?.redacted_because)
.map(summarizeMatrixRawEvent);
return {
messages,
nextBatch: res.end ?? null,
prevBatch: res.start ?? null,
};
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -273,19 +314,18 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit },
);
// matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit },
) as { chunk: MatrixRawEvent[] };
const summaries = new Map<string, MatrixReactionSummary>();
for (const event of res.events) {
const content = event.getContent<ReactionEventContent>();
const key = content["m.relates_to"].key;
for (const event of res.chunk) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
const sender = event.getSender() ?? "";
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
count: 0,
@@ -299,7 +339,7 @@ export async function listMatrixReactions(
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -311,30 +351,28 @@ export async function removeMatrixReactions(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const res = await client.relations(
resolvedRoom,
messageId,
RelationType.Annotation,
EventType.Reaction,
{ dir: Direction.Backward, limit: 200 },
);
const userId = client.getUserId();
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
{ dir: "b", limit: 200 },
) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
const targetEmoji = opts.emoji?.trim();
const toRemove = res.events
.filter((event) => event.getSender() === userId)
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
const content = event.getContent<ReactionEventContent>();
return content["m.relates_to"].key === targetEmoji;
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.getId())
.map((event) => event.event_id)
.filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 };
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -349,10 +387,10 @@ export async function pinMatrixMessage(
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.includes(messageId) ? current : [...current, messageId];
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -367,10 +405,10 @@ export async function unpinMatrixMessage(
const current = await readPinnedEvents(client, resolvedRoom);
const next = current.filter((id) => id !== messageId);
const payload: RoomPinnedEventsEventContent = { pinned: next };
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, payload);
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -395,7 +433,7 @@ export async function listMatrixPins(
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -406,20 +444,23 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const profile = await client.getProfileInfo(userId);
const member = roomId ? client.getRoom(roomId)?.getMember(userId) : undefined;
// matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
profile: {
displayName: profile?.displayname ?? null,
avatarUrl: profile?.avatar_url ?? null,
},
membership: member?.membership ?? null,
powerLevel: member?.powerLevel ?? null,
displayName: member?.name ?? null,
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
};
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}
@@ -427,20 +468,42 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const room = client.getRoom(resolvedRoom);
const topicEvent = room?.currentState.getStateEvents(EventType.RoomTopic, "");
const topicContent = topicEvent?.getContent<RoomTopicEventContent>();
const topic = typeof topicContent?.topic === "string" ? topicContent.topic : undefined;
// matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
let memberCount: number | null = null;
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
} catch { /* ignore */ }
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
} catch { /* ignore */ }
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = aliasState?.alias ?? null;
} catch { /* ignore */ }
try {
const members = await client.getJoinedRoomMembers(resolvedRoom);
memberCount = members.length;
} catch { /* ignore */ }
return {
roomId: resolvedRoom,
name: room?.name ?? null,
topic: topic ?? null,
canonicalAlias: room?.getCanonicalAlias?.() ?? null,
altAliases: room?.getAltAliases?.() ?? [],
memberCount: room?.getJoinedMemberCount?.() ?? null,
name,
topic,
canonicalAlias,
altAliases: [], // Would need separate query
memberCount,
};
} finally {
if (stopOnDone) client.stopClient();
if (stopOnDone) client.stop();
}
}

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
let activeClient: MatrixClient | null = null;

View File

@@ -32,6 +32,7 @@ describe("resolveMatrixConfig", () => {
password: "cfg-pass",
deviceName: "CfgDevice",
initialSyncLimit: 5,
encryption: false,
});
});
@@ -51,5 +52,6 @@ describe("resolveMatrixConfig", () => {
expect(resolved.password).toBe("env-pass");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
});

View File

@@ -1,4 +1,11 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import {
ConsoleLogger,
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "matrix-bot-sdk";
import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
@@ -10,22 +17,30 @@ export type MatrixResolvedConfig = {
password?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
/**
* Authenticated Matrix configuration.
* Note: deviceId is NOT included here because it's implicit in the accessToken.
* The crypto storage assumes the device ID (and thus access token) does not change
* between restarts. If the access token becomes invalid or crypto storage is lost,
* both will need to be recreated together.
*/
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;
};
type MatrixSdk = typeof import("matrix-js-sdk");
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
@@ -37,14 +52,65 @@ export function isBunRuntime(): boolean {
return typeof versions.bun === "string";
}
async function loadMatrixSdk(): Promise<MatrixSdk> {
return (await import("matrix-js-sdk")) as MatrixSdk;
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(
module: string,
messageOrObject: unknown[],
): boolean {
if (module !== "MatrixHttpClient") return false;
return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") return false;
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
});
}
function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) return;
matrixSdkLoggingConfigured = true;
LogService.setLogger({
trace: (module, ...messageOrObject) =>
matrixSdkBaseLogger.trace(module, ...messageOrObject),
debug: (module, ...messageOrObject) =>
matrixSdkBaseLogger.debug(module, ...messageOrObject),
info: (module, ...messageOrObject) =>
matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) =>
matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
matrixSdkBaseLogger.error(module, ...messageOrObject);
},
});
}
function clean(value?: string): string {
return value?.trim() ?? "";
}
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return [];
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -61,6 +127,7 @@ export function resolveMatrixConfig(
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
const encryption = matrix.encryption ?? false;
return {
homeserver,
userId,
@@ -68,9 +135,26 @@ export function resolveMatrixConfig(
password,
deviceName,
initialSyncLimit,
encryption,
};
}
export function resolveCryptoStorePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
require("node:os").homedir(),
);
const path = require("node:path");
return path.join(stateDir, "matrix", "crypto");
}
export function resolveStoragePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
require("node:os").homedir(),
);
const path = require("node:path");
return path.join(stateDir, "matrix", "bot-storage.json");
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
@@ -81,9 +165,6 @@ export async function resolveMatrixAuth(params?: {
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
if (!resolved.userId) {
throw new Error("Matrix userId is required (matrix.userId)");
}
const {
loadMatrixCredentials,
@@ -97,21 +178,36 @@ export async function resolveMatrixAuth(params?: {
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId,
userId: resolved.userId || "",
})
? cached
: null;
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
let userId = resolved.userId;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken);
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId: resolved.userId,
userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
@@ -123,25 +219,45 @@ export async function resolveMatrixAuth(params?: {
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
}
if (!resolved.userId) {
throw new Error(
"Matrix userId is required when no access token is configured (matrix.userId)",
);
}
if (!resolved.password) {
throw new Error(
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
"Matrix password is required when no access token is configured (matrix.password)",
);
}
const sdk = await loadMatrixSdk();
const loginClient = sdk.createClient({
baseUrl: resolved.homeserver,
});
const login = await loginClient.loginRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
@@ -153,12 +269,14 @@ export async function resolveMatrixAuth(params?: {
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
return auth;
@@ -168,21 +286,79 @@ export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
localTimeoutMs?: number;
}): Promise<MatrixClient> {
const sdk = await loadMatrixSdk();
const store = new sdk.MemoryStore();
return sdk.createClient({
baseUrl: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
localTimeoutMs: params.localTimeoutMs,
store,
});
ensureMatrixSdkLoggingConfigured();
const env = process.env;
// Create storage provider
const storagePath = resolveStoragePath(env);
const fs = await import("node:fs");
const path = await import("node:path");
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
const cryptoPath = resolveCryptoStorePath(env);
fs.mkdirSync(cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(cryptoPath, StoreType.Sqlite);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
}
}
const client = new MatrixClient(
params.homeserver,
params.accessToken,
storage,
cryptoStorage,
);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
}
function buildSharedClientKey(auth: MatrixAuth): string {
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
}
async function createSharedMatrixClient(params: {
@@ -193,15 +369,22 @@ async function createSharedMatrixClient(params: {
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
});
return { client, key: buildSharedClientKey(params.auth), started: false };
return {
client,
key: buildSharedClientKey(params.auth),
started: false,
cryptoReady: false,
};
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
encryption?: boolean;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
@@ -209,18 +392,22 @@ async function ensureSharedClientStarted(params: {
return;
}
sharedClientStartPromise = (async () => {
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
lazyLoadMembers: true,
threadSupport: true,
};
if (typeof params.initialSyncLimit === "number") {
startOpts.initialSyncLimit = params.initialSyncLimit;
const client = params.state.client;
// Initialize crypto if enabled
if (params.encryption && !params.state.cryptoReady) {
try {
const joinedRooms = await client.getJoinedRooms();
if (client.crypto) {
await client.crypto.prepare(joinedRooms);
params.state.cryptoReady = true;
}
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err);
}
}
await params.state.client.startClient(startOpts);
await waitForMatrixSync({
client: params.state.client,
timeoutMs: params.timeoutMs,
});
await client.start();
params.state.started = true;
})();
try {
@@ -249,6 +436,7 @@ export async function resolveSharedMatrixClient(
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
@@ -262,11 +450,12 @@ export async function resolveSharedMatrixClient(
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
}
pending.client.stopClient();
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
}
@@ -283,6 +472,7 @@ export async function resolveSharedMatrixClient(
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return created.client;
@@ -291,48 +481,18 @@ export async function resolveSharedMatrixClient(
}
}
export async function waitForMatrixSync(params: {
export async function waitForMatrixSync(_params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
if (params.client.getSyncState() === SyncState.Syncing) return;
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
const cleanup = () => {
if (done) return;
done = true;
params.client.removeListener(ClientEvent.Sync, onSync);
if (params.abortSignal) {
params.abortSignal.removeEventListener("abort", onAbort);
}
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
const onSync = (state: SyncState) => {
if (done) return;
if (state === SyncState.Prepared || state === SyncState.Syncing) {
cleanup();
resolve();
}
if (state === SyncState.Error) {
cleanup();
reject(new Error("Matrix sync failed"));
}
};
const onAbort = () => {
cleanup();
reject(new Error("Matrix sync aborted"));
};
params.client.on(ClientEvent.Sync, onSync);
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
cleanup();
reject(new Error("Matrix sync timed out"));
}, timeoutMs);
});
// matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
}
}

View File

@@ -8,6 +8,7 @@ export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
@@ -94,5 +95,9 @@ export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string },
): boolean {
// If userId is empty (token-based auth), only match homeserver
if (!config.userId) {
return stored.homeserver === config.homeserver;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return;
const confirm = params.confirm;
if (confirm) {
const ok = await confirm("Matrix requires matrix-js-sdk. Install now?");
const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?");
if (!ok) {
throw new Error("Matrix requires matrix-js-sdk (install dependencies first).");
throw new Error("Matrix requires matrix-bot-sdk (install dependencies first).");
}
}
@@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
throw new Error("Matrix dependency install completed but matrix-js-sdk is still missing.");
throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing.");
}
}

View File

@@ -3,6 +3,7 @@ export { probeMatrix } from "./probe.js";
export {
reactMatrixMessage,
resolveMatrixRoomId,
sendReadReceiptMatrix,
sendMessageMatrix,
sendPollMatrix,
sendTypingMatrix,

View File

@@ -1,5 +1,5 @@
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
import { RoomMemberEvent } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import { AutojoinRoomsMixin } from "matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
@@ -19,25 +19,40 @@ export function registerMatrixAutoJoin(params: {
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
client.on(RoomMemberEvent.Membership, async (_event: MatrixEvent, member: RoomMember) => {
if (member.userId !== client.getUserId()) return;
if (member.membership !== "invite") return;
const roomId = member.roomId;
if (autoJoin === "off") return;
if (autoJoin === "allowlist") {
const invitedRoom = client.getRoom(roomId);
const alias = invitedRoom?.getCanonicalAlias?.() ?? "";
const altAliases = invitedRoom?.getAltAliases?.() ?? [];
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;
}
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
// Get room alias if available
let alias: string | undefined;
try {
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
alias = aliasState?.alias;
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false);
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);

View File

@@ -1,80 +1,105 @@
import type {
AccountDataEvents,
MatrixClient,
MatrixEvent,
Room,
RoomMember,
} from "matrix-js-sdk";
import { ClientEvent, EventType } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
function hasDirectFlag(member?: RoomMember | null): boolean {
if (!member?.events.member) return false;
const content = member.events.member.getContent() as { is_direct?: boolean } | undefined;
if (content?.is_direct === true) return true;
const prev = member.events.member.getPrevContent() as { is_direct?: boolean } | undefined;
return prev?.is_direct === true;
}
type DirectMessageCheck = {
roomId: string;
senderId?: string;
selfUserId?: string;
};
export function isLikelyDirectRoom(params: {
room: Room;
senderId: string;
selfId?: string | null;
}): boolean {
if (!params.selfId) return false;
const memberCount = params.room.getJoinedMemberCount?.();
if (typeof memberCount !== "number" || memberCount !== 2) return false;
return true;
}
type DirectRoomTrackerOptions = {
log?: (message: string) => void;
};
export function isDirectRoomByFlag(params: {
room: Room;
senderId: string;
selfId?: string | null;
}): boolean {
if (!params.selfId) return false;
const selfMember = params.room.getMember(params.selfId);
const senderMember = params.room.getMember(params.senderId);
if (hasDirectFlag(selfMember) || hasDirectFlag(senderMember)) return true;
const inviter = selfMember?.getDMInviter() ?? senderMember?.getDMInviter();
return Boolean(inviter);
}
const DM_CACHE_TTL_MS = 30_000;
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
export function createDirectRoomTracker(
client: MatrixClient,
opts: DirectRoomTrackerOptions = {},
) {
const log = opts.log ?? (() => {});
let lastDmUpdateMs = 0;
let cachedSelfUserId: string | null = null;
const memberCountCache = new Map<string, { count: number; ts: number }>();
export function createDirectRoomTracker(client: MatrixClient) {
const directMap = new Map<string, Set<string>>();
const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) return cachedSelfUserId;
try {
cachedSelfUserId = await client.getUserId();
} catch {
cachedSelfUserId = null;
}
return cachedSelfUserId;
};
const updateDirectMap = (content: MatrixDirectAccountData) => {
directMap.clear();
for (const [userId, rooms] of Object.entries(content)) {
if (!Array.isArray(rooms)) continue;
const ids = rooms.map((roomId) => String(roomId).trim()).filter(Boolean);
if (ids.length === 0) continue;
directMap.set(userId, new Set(ids));
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
lastDmUpdateMs = now;
try {
await client.dms.update();
} catch (err) {
log(`matrix: dm cache refresh failed (${String(err)})`);
}
};
const initialDirect = client.getAccountData(EventType.Direct);
if (initialDirect) {
updateDirectMap(initialDirect.getContent<MatrixDirectAccountData>() ?? {});
}
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
const cached = memberCountCache.get(roomId);
const now = Date.now();
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
return cached.count;
}
try {
const members = await client.getJoinedRoomMembers(roomId);
const count = members.length;
memberCountCache.set(roomId, { count, ts: now });
return count;
} catch (err) {
log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
return null;
}
};
client.on(ClientEvent.AccountData, (event: MatrixEvent) => {
if (event.getType() !== EventType.Direct) return;
updateDirectMap(event.getContent<MatrixDirectAccountData>() ?? {});
});
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim();
if (!target) return false;
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;
} catch {
return false;
}
};
return {
isDirectMessage: (room: Room, senderId: string) => {
const roomId = room.roomId;
const directRooms = directMap.get(senderId);
const selfId = client.getUserId();
const isDirectByFlag = isDirectRoomByFlag({ room, senderId, selfId });
return (
Boolean(directRooms?.has(roomId)) ||
isDirectByFlag ||
isLikelyDirectRoom({ room, senderId, selfId })
isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
const { roomId, senderId } = params;
await refreshDmCache();
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}
const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
return true;
}
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const directViaState =
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
if (directViaState) {
log(`matrix: dm detected via member state room=${roomId}`);
return true;
}
log(
`matrix: dm check room=${roomId} result=group members=${
memberCount ?? "unknown"
}`,
);
return false;
},
};
}

View File

@@ -1,11 +1,17 @@
import type { MatrixEvent, Room } from "matrix-js-sdk";
import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import type {
LocationMessageEventContent,
MatrixClient,
MessageEventContent,
} from "matrix-bot-sdk";
import { format } from "node:util";
import {
formatAllowlistMatchMeta,
formatLocationText,
mergeAllowlist,
summarizeMapping,
toLocationContext,
type NormalizedLocation,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
@@ -15,6 +21,7 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClient,
} from "../client.js";
import {
formatPollAsText,
@@ -22,7 +29,12 @@ import {
type PollStartContent,
parsePollStartContent,
} from "../poll-types.js";
import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
import {
reactMatrixMessage,
sendMessageMatrix,
sendReadReceiptMatrix,
sendTypingMatrix,
} from "../send.js";
import {
resolveMatrixAllowListMatch,
resolveMatrixAllowListMatches,
@@ -38,6 +50,118 @@ import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
// Constants that were previously from matrix-js-sdk
const EventType = {
RoomMessage: "m.room.message",
RoomMessageEncrypted: "m.room.encrypted",
RoomMember: "m.room.member",
Location: "m.location",
} as const;
const RelationType = {
Replace: "m.replace",
} as const;
// Type for raw Matrix events from matrix-bot-sdk
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
};
type RoomMessageEventContent = MessageEventContent & {
url?: string;
info?: {
mimetype?: string;
};
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
type MatrixLocationPayload = {
text: string;
context: ReturnType<typeof toLocationContext>;
};
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<string, string>();
for (const part of paramParts) {
const segment = part.trim();
if (!segment) continue;
const eqIndex = segment.indexOf("=");
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const valuePart = rawValue.trim();
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
}
const accuracyRaw = params.get("u");
const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined;
return {
latitude,
longitude,
accuracy: Number.isFinite(accuracy) ? accuracy : undefined,
};
}
function resolveMatrixLocation(params: {
eventType: string;
content: LocationMessageEventContent;
}): MatrixLocationPayload | null {
const { eventType, content } = params;
const isLocation =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
if (!isLocation) return null;
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
if (!geoUri) return null;
const parsed = parseGeoUri(geoUri);
if (!parsed) return null;
const caption = typeof content.body === "string" ? content.body.trim() : "";
const location: NormalizedLocation = {
latitude: parsed.latitude,
longitude: parsed.longitude,
accuracy: parsed.accuracy,
caption: caption || undefined,
source: "pin",
isLive: false,
};
return {
text: formatLocationText(location),
context: toLocationContext(location),
};
}
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
@@ -56,13 +180,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return;
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
log: (...args) => {
logger.info(formatRuntimeMessage(...args));
},
error: (...args) => {
logger.error(formatRuntimeMessage(...args));
},
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
logger.debug(message);
};
const normalizeUserEntry = (raw: string) =>
raw.replace(/^matrix:/i, "").replace(/^user:/i, "").trim();
@@ -70,8 +204,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
raw.replace(/^matrix:/i, "").replace(/^(room|channel):/i, "").trim();
const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":");
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
let roomsConfig = cfg.channels?.matrix?.rooms;
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
if (allowFrom.length > 0) {
const entries = allowFrom
@@ -163,7 +298,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm,
allowFrom,
},
rooms: roomsConfig,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
@@ -185,13 +320,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
logger.debug(message);
}
};
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
@@ -206,30 +334,75 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
const directTracker = createDirectRoomTracker(client);
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
const warnedCryptoMissingRooms = new Set<string>();
const handleTimeline = async (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline?: boolean,
const roomInfoCache = new Map<
string,
{ name?: string; canonicalAlias?: string; altAliases: string[] }
>();
// Helper to get room info
const getRoomInfo = async (roomId: string) => {
const cached = roomInfoCache.get(roomId);
if (cached) return cached;
let name: string | undefined;
let canonicalAlias: string | undefined;
let altAliases: string[] = [];
try {
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
name = nameState?.name;
} catch { /* ignore */ }
try {
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
canonicalAlias = aliasState?.alias;
altAliases = aliasState?.alt_aliases ?? [];
} catch { /* ignore */ }
const info = { name, canonicalAlias, altAliases };
roomInfoCache.set(roomId, info);
return info;
};
// Helper to get member display name
const getMemberDisplayName = async (roomId: string, userId: string): Promise<string> => {
try {
const memberState = await client.getRoomStateEvent(roomId, "m.room.member", userId).catch(() => null);
return memberState?.displayname ?? userId;
} catch {
return userId;
}
};
const handleRoomMessage = async (
roomId: string,
event: MatrixRawEvent,
) => {
try {
if (!room) return;
if (toStartOfTimeline) return;
if (event.getType() === EventType.RoomMessageEncrypted || event.isDecryptionFailure()) {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
return;
}
const eventType = event.getType();
const isPollEvent = isPollStartType(eventType);
if (eventType !== EventType.RoomMessage && !isPollEvent) return;
if (event.isRedacted()) return;
const senderId = event.getSender();
const locationContent = event.content as LocationMessageEventContent;
const isLocationEvent =
eventType === EventType.Location ||
(eventType === EventType.RoomMessage &&
locationContent.msgtype === EventType.Location);
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
logVerboseMessage(
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
);
if (event.unsigned?.redacted_because) return;
const senderId = event.sender;
if (!senderId) return;
if (senderId === client.getUserId()) return;
const eventTs = event.getTs();
const eventAge = event.getAge();
const selfUserId = await client.getUserId();
if (senderId === selfUserId) return;
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
}
@@ -241,15 +414,23 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
let content = event.getContent<RoomMessageEventContent>();
const roomInfo = await getRoomInfo(roomId);
const roomName = roomInfo.name;
const roomAliases = [
roomInfo.canonicalAlias ?? "",
...roomInfo.altAliases,
].filter(Boolean);
let content = event.content as RoomMessageEventContent;
if (isPollEvent) {
const pollStartContent = event.getContent<PollStartContent>();
const pollStartContent = event.content as PollStartContent;
const pollSummary = parsePollStartContent(pollStartContent);
if (pollSummary) {
pollSummary.eventId = event.getId() ?? "";
pollSummary.roomId = room.roomId;
pollSummary.eventId = event.event_id ?? "";
pollSummary.roomId = roomId;
pollSummary.sender = senderId;
pollSummary.senderName = room.getMember(senderId)?.name ?? senderId;
const senderDisplayName = await getMemberDisplayName(roomId, senderId);
pollSummary.senderName = senderDisplayName;
const pollText = formatPollAsText(pollSummary);
content = {
msgtype: "m.text",
@@ -260,50 +441,64 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
const locationPayload = resolveMatrixLocation({
eventType,
content: content as LocationMessageEventContent,
});
const relates = content["m.relates_to"];
if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) return;
}
const roomId = room.roomId;
const isDirectMessage = directTracker.isDirectMessage(room, senderId);
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
senderId,
selfUserId,
});
const isRoom = !isDirectMessage;
if (!isDirectMessage && groupPolicy === "disabled") return;
if (isRoom && groupPolicy === "disabled") return;
const roomAliases = [
room.getCanonicalAlias?.() ?? "",
...(room.getAltAliases?.() ?? []),
].filter(Boolean);
const roomName = room.name ?? undefined;
const roomConfigInfo = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms,
roomId,
aliases: roomAliases,
name: roomName,
});
const roomMatchMeta = `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`;
const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({
rooms: roomsConfig,
roomId,
aliases: roomAliases,
name: roomName,
})
: undefined;
const roomConfig = roomConfigInfo?.config;
const roomMatchMeta = roomConfigInfo
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
roomConfigInfo.matchSource ?? "none"
}`
: "matchKey=none matchSource=none";
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = room.getMember(senderId)?.name ?? senderId;
const senderName = await getMemberDisplayName(roomId, senderId);
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? [];
const effectiveGroupAllowFrom = normalizeAllowListLower([
...groupAllowFrom,
...storeAllowFrom,
]);
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") return;
@@ -353,9 +548,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
if (isRoom && roomConfigInfo.config?.users?.length) {
const roomUsers = roomConfig?.users ?? [];
if (isRoom && roomUsers.length > 0) {
const userMatch = resolveMatrixAllowListMatch({
allowList: normalizeAllowListLower(roomConfigInfo.config.users),
allowList: normalizeAllowListLower(roomUsers),
userId: senderId,
userName: senderName,
});
@@ -368,11 +564,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
}
if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) {
const groupAllowMatch = resolveMatrixAllowListMatch({
allowList: effectiveGroupAllowFrom,
userId: senderId,
userName: senderName,
});
if (!groupAllowMatch.allowed) {
logVerboseMessage(
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
groupAllowMatch,
)})`,
);
return;
}
}
if (isRoom) {
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
const rawBody = locationPayload?.text
?? (typeof content.body === "string" ? content.body.trim() : "");
let media: {
path: string;
contentType?: string;
@@ -406,7 +618,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const { wasMentioned, hasExplicitMention } = resolveMentions({
content,
userId: client.getUserId(),
userId: selfUserId,
text: bodyText,
mentionRegexes,
});
@@ -420,10 +632,27 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userId: senderId,
userName: senderName,
});
const senderAllowedForGroup = groupAllowConfigured
? resolveMatrixAllowListMatches({
allowList: effectiveGroupAllowFrom,
userId: senderId,
userName: senderName,
})
: false;
const senderAllowedForRoomUsers =
isRoom && roomUsers.length > 0
? resolveMatrixAllowListMatches({
allowList: normalizeAllowListLower(roomUsers),
userId: senderId,
userName: senderName,
})
: false;
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
});
if (
@@ -436,12 +665,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
const shouldRequireMention = isRoom
? roomConfigInfo.config?.autoReply === true
? roomConfig?.autoReply === true
? false
: roomConfigInfo.config?.autoReply === false
: roomConfig?.autoReply === false
? true
: typeof roomConfigInfo.config?.requireMention === "boolean"
? roomConfigInfo.config.requireMention
: typeof roomConfig?.requireMention === "boolean"
? roomConfig?.requireMention
: true
: false;
const shouldBypassMention =
@@ -457,13 +686,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
const messageId = event.getId() ?? "";
const messageId = event.event_id ?? "";
const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id;
const threadRootId = resolveMatrixThreadRootId({ event, content });
const threadTarget = resolveMatrixThreadTarget({
threadReplies,
messageId,
threadRootId,
isThreadRoot: event.isThreadRoot,
isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
@@ -484,16 +714,16 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: event.getTs() ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: eventTs ?? undefined,
previousTimestamp,
envelope: envelopeOptions,
body: textWithId,
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
@@ -508,18 +738,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const,
Surface: "matrix" as const,
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: messageId,
ReplyToId: threadTarget ? undefined : (event.replyEventId ?? undefined),
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
MessageThreadId: threadTarget,
Timestamp: event.getTs() ?? undefined,
Timestamp: eventTs ?? undefined,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
...(locationPayload?.context ?? {}),
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
OriginatingChannel: "matrix" as const,
@@ -577,6 +808,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return;
}
if (messageId) {
sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
logVerboseMessage(
`matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
);
});
}
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
@@ -606,7 +845,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfigInfo.config?.skills,
skillFilter: roomConfig?.skills,
},
});
markDispatchIdle();
@@ -628,15 +867,100 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
};
client.on(RoomEvent.Timeline, handleTimeline);
// matrix-bot-sdk uses on("room.message", handler)
client.on("room.message", handleRoomMessage);
await resolveSharedMatrixClient({ cfg, auth: authWithLimit, startClient: true });
runtime.log?.(`matrix: logged in as ${auth.userId}`);
client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const eventType = event?.type ?? "unknown";
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
});
// Handle failed E2EE decryption
client.on("room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => {
logger.warn({ roomId, eventId: event.event_id, error: error.message }, "Failed to decrypt message");
logVerboseMessage(
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`,
);
});
client.on("room.invite", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
const sender = event?.sender ?? "unknown";
const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true;
logVerboseMessage(
`matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`,
);
});
client.on("room.join", (roomId: string, event: MatrixRawEvent) => {
const eventId = event?.event_id ?? "unknown";
logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`);
});
client.on("room.event", (roomId: string, event: MatrixRawEvent) => {
const eventType = event?.type ?? "unknown";
if (eventType === EventType.RoomMessageEncrypted) {
logVerboseMessage(
`matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`,
);
if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) {
warnedEncryptedRooms.add(roomId);
const warning =
"matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt";
logger.warn({ roomId }, warning);
}
if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) {
warnedCryptoMissingRooms.add(roomId);
const warning =
"matrix: encryption enabled but crypto is unavailable; install @matrix-org/matrix-sdk-crypto-nodejs and restart";
logger.warn({ roomId }, warning);
}
return;
}
if (eventType === EventType.RoomMember) {
const membership = (event?.content as { membership?: string } | undefined)?.membership;
const stateKey = (event as { state_key?: string }).state_key ?? "";
logVerboseMessage(
`matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`,
);
}
});
logVerboseMessage("matrix: starting client");
await resolveSharedMatrixClient({
cfg,
auth: authWithLimit,
});
logVerboseMessage("matrix: client started");
// matrix-bot-sdk client is already started via resolveSharedMatrixClient
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification
if (auth.encryption && client.crypto) {
try {
// Request verification from other sessions
const verificationRequest = await client.crypto.requestOwnUserVerification();
if (verificationRequest) {
logger.info("matrix: device verification requested - please verify in another client");
}
} catch (err) {
logger.debug({ error: String(err) }, "Device verification request failed (may already be verified)");
}
}
await new Promise<void>((resolve) => {
const onAbort = () => {
try {
client.stopClient();
logVerboseMessage("matrix: stopping client");
stopSharedClient();
} finally {
setActiveMatrixClient(null);
resolve();

View File

@@ -1,35 +1,68 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
// Type for encrypted file info
type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
const url = params.client.mxcUrlToHttp(
params.mxcUrl,
undefined,
undefined,
undefined,
false,
true,
true,
);
// matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
const token = params.client.getAccessToken();
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
if (!res.ok) {
throw new Error(`Matrix media download failed: HTTP ${res.status}`);
// Use the client's download method which handles auth
try {
const buffer = await params.client.downloadContent(params.mxcUrl);
if (buffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
return { buffer: Buffer.from(buffer) };
} catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) {
}
/**
* Download and decrypt encrypted media from a Matrix room.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
file: EncryptedFile;
maxBytes: number;
}): Promise<{ buffer: Buffer } | null> {
if (!params.client.crypto) {
throw new Error("Cannot decrypt media: crypto not enabled");
}
// Download the encrypted content
const encryptedBuffer = await params.client.downloadContent(params.file.url);
if (encryptedBuffer.byteLength > params.maxBytes) {
throw new Error("Matrix media exceeds configured size limit");
}
const headerType = res.headers.get("content-type") ?? undefined;
return { buffer, headerType };
// Decrypt using matrix-bot-sdk crypto
const decrypted = await params.client.crypto.decryptMedia(
Buffer.from(encryptedBuffer),
params.file,
);
return { buffer: decrypted };
}
export async function downloadMatrixMedia(params: {
@@ -37,16 +70,30 @@ export async function downloadMatrixMedia(params: {
mxcUrl: string;
contentType?: string;
maxBytes: number;
file?: EncryptedFile;
}): Promise<{
path: string;
contentType?: string;
placeholder: string;
} | null> {
const fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
let fetched: { buffer: Buffer; headerType?: string } | null;
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
client: params.client,
file: params.file,
maxBytes: params.maxBytes,
});
} else {
// Unencrypted media
fetched = await fetchMatrixMediaBuffer({
client: params.client,
mxcUrl: params.mxcUrl,
maxBytes: params.maxBytes,
});
}
if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(

View File

@@ -1,16 +1,22 @@
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import { getMatrixRuntime } from "../../runtime.js";
// Type for room message content with mentions
type MessageContentWithMentions = {
msgtype: string;
body: string;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
};
export function resolveMentions(params: {
content: RoomMessageEventContent;
content: MessageContentWithMentions;
userId?: string | null;
text?: string;
mentionRegexes: RegExp[];
}) {
const mentions = params.content["m.mentions"] as
| { user_ids?: string[]; room?: boolean }
| undefined;
const mentions = params.content["m.mentions"];
const mentionedUsers = Array.isArray(mentions?.user_ids)
? new Set(mentions.user_ids)
: new Set<string>();

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "matrix-js-sdk";
import type { MatrixClient } from "matrix-bot-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";

View File

@@ -1,4 +1,4 @@
import type { MatrixConfig, MatrixRoomConfig } from "../../types.js";
import type { MatrixRoomConfig } from "../../types.js";
import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "clawdbot/plugin-sdk";
export type MatrixRoomConfigResolved = {
@@ -10,7 +10,7 @@ export type MatrixRoomConfigResolved = {
};
export function resolveMatrixRoomConfig(params: {
rooms?: MatrixConfig["rooms"];
rooms?: Record<string, MatrixRoomConfig>;
roomId: string;
aliases: string[];
name?: string | null;

View File

@@ -1,6 +1,25 @@
import type { MatrixEvent } from "matrix-js-sdk";
import { RelationType } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
// Type for raw Matrix event from matrix-bot-sdk
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
};
type RoomMessageEventContent = {
msgtype: string;
body: string;
"m.relates_to"?: {
rel_type?: string;
event_id?: string;
"m.in_reply_to"?: { event_id?: string };
};
};
const RelationType = {
Thread: "m.thread",
} as const;
export function resolveMatrixThreadTarget(params: {
threadReplies: "off" | "inbound" | "always";
@@ -22,13 +41,9 @@ export function resolveMatrixThreadTarget(params: {
}
export function resolveMatrixThreadRootId(params: {
event: MatrixEvent;
event: MatrixRawEvent;
content: RoomMessageEventContent;
}): string | undefined {
const fromThread = params.event.getThread?.()?.id;
if (fromThread) return fromThread;
const direct = params.event.threadRootId ?? undefined;
if (direct) return direct;
const relates = params.content["m.relates_to"];
if (!relates || typeof relates !== "object") return undefined;
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {

View File

@@ -7,8 +7,6 @@
* - m.poll.end - Closes a poll
*/
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
import type { PollInput } from "clawdbot/plugin-sdk";
export const M_POLL_START = "m.poll.start" as const;
@@ -34,7 +32,9 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = ExtensibleAnyMessageEventContent & {
export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string;
};
@@ -53,7 +53,13 @@ export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
export type PollStartContent = {
[M_POLL_START]?: PollStartSubtype;
[ORG_POLL_START]?: PollStartSubtype;
"m.poll"?: PollStartSubtype;
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
};
export type PollSummary = {
eventId: string;

View File

@@ -49,9 +49,10 @@ export async function probeMatrix(params: {
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
const res = await client.whoami();
// matrix-bot-sdk uses getUserId() which calls whoami internally
const userId = await client.getUserId();
result.ok = true;
result.userId = res.user_id ?? null;
result.userId = userId ?? null;
result.elapsedMs = Date.now() - started;
return result;
@@ -59,8 +60,8 @@ export async function probeMatrix(params: {
return {
...result,
status:
typeof err === "object" && err && "httpStatus" in err
? Number((err as { httpStatus?: number }).httpStatus)
typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,

View File

@@ -3,22 +3,20 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-js-sdk", () => ({
EventType: {
Direct: "m.direct",
RoomMessage: "m.room.message",
Reaction: "m.reaction",
vi.mock("matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
MsgType: {
Text: "m.text",
File: "m.file",
Image: "m.image",
Audio: "m.audio",
Video: "m.video",
},
RelationType: {
Annotation: "m.annotation",
LogService: {
setLogger: vi.fn(),
},
MatrixClient: vi.fn(),
SimpleFsStorageProvider: vi.fn(),
RustSdkCryptoStorageProvider: vi.fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
@@ -52,14 +50,13 @@ const runtimeStub = {
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue({ event_id: "evt1" });
const uploadContent = vi.fn().mockResolvedValue({
content_uri: "mxc://example/file",
});
const sendMessage = vi.fn().mockResolvedValue("evt1");
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const client = {
sendMessage,
uploadContent,
} as unknown as import("matrix-js-sdk").MatrixClient;
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("matrix-bot-sdk").MatrixClient;
return { client, sendMessage, uploadContent };
};
@@ -96,4 +93,41 @@ describe("sendMessageMatrix media", () => {
expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file");
});
it("uploads encrypted media with file payloads", async () => {
const { client, sendMessage, uploadContent } = makeClient();
(client as { crypto?: object }).crypto = {
isRoomEncrypted: vi.fn().mockResolvedValue(true),
encryptMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("encrypted"),
file: {
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
},
}),
};
await sendMessageMatrix("room:!room:example", "caption", {
client,
mediaUrl: "file:///tmp/photo.png",
});
const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined;
expect(uploadArg?.toString()).toBe("encrypted");
const content = sendMessage.mock.calls[0]?.[1] as {
url?: string;
file?: { url?: string };
};
expect(content.url).toBeUndefined();
expect(content.file?.url).toBe("mxc://example/file");
});
});

View File

@@ -1,9 +1,14 @@
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
import { EventType, MsgType, RelationType } from "matrix-js-sdk";
import type {
RoomMessageEventContent,
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
DimensionalFileInfo,
EncryptedFile,
FileWithThumbnailInfo,
MessageEventContent,
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
MatrixClient,
} from "matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
@@ -13,7 +18,6 @@ import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
waitForMatrixSync,
} from "./client.js";
import { markdownToMatrixHtml } from "./format.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
@@ -22,18 +26,63 @@ import type { CoreConfig } from "../types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
// Message types
const MsgType = {
Text: "m.text",
Image: "m.image",
Audio: "m.audio",
Video: "m.video",
File: "m.file",
Notice: "m.notice",
} as const;
// Relation types
const RelationType = {
Annotation: "m.annotation",
Replace: "m.replace",
Thread: "m.thread",
} as const;
// Event types
const EventType = {
Direct: "m.direct",
Reaction: "m.reaction",
RoomMessage: "m.room.message",
} as const;
type MatrixDirectAccountData = Record<string, string[]>;
type MatrixReplyRelation = {
"m.in_reply_to": { event_id: string };
};
type MatrixMessageContent = Record<string, unknown> & {
msgtype: MsgType;
body: string;
type MatrixReplyMeta = {
"m.relates_to"?: MatrixReplyRelation;
};
type MatrixUploadContent = Parameters<MatrixClient["uploadContent"]>[0];
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta;
type MatrixMediaContent = MessageEventContent &
MatrixReplyMeta & {
info?: MatrixMediaInfo;
url?: string;
file?: EncryptedFile;
filename?: string;
"org.matrix.msc3245.voice"?: Record<string, never>;
"org.matrix.msc1767.audio"?: { duration: number };
};
type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent;
type ReactionEventContent = {
"m.relates_to": {
rel_type: typeof RelationType.Annotation;
event_id: string;
key: string;
};
};
export type MatrixSendResult = {
messageId: string;
@@ -83,13 +132,14 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
if (!trimmed.startsWith("@")) {
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
}
const directEvent = client.getAccountData(EventType.Direct);
const directContent = directEvent?.getContent<MatrixDirectAccountData>();
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) return list[0];
const server = await client.getAccountDataFromServer(EventType.Direct);
const serverList = Array.isArray(server?.[trimmed]) ? server[trimmed] : [];
if (serverList.length > 0) return serverList[0];
// matrix-bot-sdk: use getAccountData to retrieve m.direct
try {
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) return list[0];
} catch {
// Ignore errors, try fetching from server
}
throw new Error(
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
);
@@ -117,75 +167,116 @@ export async function resolveMatrixRoomId(
return await resolveDirectRoomId(client, target);
}
if (target.startsWith("#")) {
const resolved = await client.getRoomIdForAlias(target);
if (!resolved?.room_id) {
const resolved = await client.resolveRoom(target);
if (!resolved) {
throw new Error(`Matrix alias ${target} could not be resolved`);
}
return resolved.room_id;
return resolved;
}
return target;
}
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
type MatrixMediaMsgType =
| typeof MsgType.Image
| typeof MsgType.Audio
| typeof MsgType.Video
| typeof MsgType.File;
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
function buildMatrixMediaInfo(params: {
size: number;
mimetype?: string;
durationMs?: number;
imageInfo?: DimensionalFileInfo;
}): MatrixMediaInfo | undefined {
const base: FileWithThumbnailInfo = {};
if (Number.isFinite(params.size)) {
base.size = params.size;
}
if (params.mimetype) {
base.mimetype = params.mimetype;
}
if (params.imageInfo) {
const dimensional: DimensionalFileInfo = {
...base,
...params.imageInfo,
};
if (typeof params.durationMs === "number") {
const videoInfo: VideoFileInfo = {
...dimensional,
duration: params.durationMs,
};
return videoInfo;
}
return dimensional;
}
if (typeof params.durationMs === "number") {
const timedInfo: TimedFileInfo = {
...base,
duration: params.durationMs,
};
return timedInfo;
}
if (Object.keys(base).length === 0) return undefined;
return base;
}
type MatrixFormattedContent = MessageEventContent & {
format?: string;
formatted_body?: string;
};
function buildMediaContent(params: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
msgtype: MatrixMediaMsgType;
body: string;
url: string;
url?: string;
filename?: string;
mimetype?: string;
size: number;
relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent {
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = {
imageInfo?: DimensionalFileInfo;
file?: EncryptedFile; // For encrypted media
}): MatrixMediaContent {
const info = buildMatrixMediaInfo({
size: params.size,
mimetype: params.mimetype,
durationMs: params.durationMs,
imageInfo: params.imageInfo,
});
const base: MatrixMediaContent = {
msgtype: params.msgtype,
body: params.body,
filename: params.filename,
info,
url: params.url,
info: info ?? undefined,
};
// Encrypted media should only include the "file" payload, not top-level "url".
if (!params.file && params.url) {
base.url = params.url;
}
// For encrypted files, add the file object
if (params.file) {
base.file = params.file;
}
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
if (typeof params.durationMs === "number") {
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
}
if (params.relation) {
base["m.relates_to"] = params.relation;
}
applyMatrixFormatting(base, params.body);
return base as RoomMessageEventContent;
return base;
}
function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMessageEventContent {
const content: MatrixMessageContent = relation
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
body,
@@ -196,10 +287,10 @@ function buildTextContent(body: string, relation?: MatrixReplyRelation): RoomMes
body,
};
applyMatrixFormatting(content, body);
return content as RoomMessageEventContent;
return content;
}
function applyMatrixFormatting(content: MatrixMessageContent, body: string): void {
function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) return;
content.format = "org.matrix.custom.html";
@@ -215,7 +306,7 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
): MatrixMediaMsgType {
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
@@ -247,10 +338,10 @@ const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
}): Promise<DimensionalFileInfo | undefined> {
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
@@ -261,11 +352,12 @@ async function prepareImageInfo(params: {
withoutEnlargement: true,
});
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
const thumbUri = await params.client.uploadContent(
thumbBuffer,
"image/jpeg",
"thumbnail.jpg",
);
imageInfo.thumbnail_url = thumbUri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
@@ -281,21 +373,76 @@ async function prepareImageInfo(params: {
return imageInfo;
}
async function resolveMediaDurationMs(params: {
buffer: Buffer;
contentType?: string;
fileName?: string;
kind: MediaKind;
}): Promise<number | undefined> {
if (params.kind !== "audio" && params.kind !== "video") return undefined;
try {
const fileInfo: IFileInfo | string | undefined =
params.contentType || params.fileName
? {
mimeType: params.contentType,
size: params.buffer.byteLength,
path: params.fileName,
}
: undefined;
const metadata = await parseBuffer(params.buffer, fileInfo, {
duration: true,
skipCovers: true,
});
const durationSeconds = metadata.format.duration;
if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) {
return Math.max(0, Math.round(durationSeconds * 1000));
}
} catch {
// Duration is optional; ignore parse failures.
}
return undefined;
}
async function uploadFile(
client: MatrixClient,
file: MatrixUploadContent | Buffer,
file: Buffer,
params: {
contentType?: string;
filename?: string;
includeFilename?: boolean;
},
): Promise<string> {
const upload = await client.uploadContent(file as MatrixUploadContent, {
type: params.contentType,
name: params.filename,
includeFilename: params.includeFilename,
});
return upload.content_uri;
return await client.uploadContent(file, params.contentType, params.filename);
}
/**
* Upload media with optional encryption for E2EE rooms.
*/
async function uploadMediaMaybeEncrypted(
client: MatrixClient,
roomId: string,
buffer: Buffer,
params: {
contentType?: string;
filename?: string;
},
): Promise<{ url: string; file?: EncryptedFile }> {
// Check if room is encrypted and crypto is available
const isEncrypted = client.crypto && await client.crypto.isRoomEncrypted(roomId);
if (isEncrypted && client.crypto) {
// Encrypt the media before uploading
const encrypted = await client.crypto.encryptMedia(buffer);
const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename);
const file: EncryptedFile = { url: mxc, ...encrypted.file };
return {
url: mxc,
file,
};
}
// Upload unencrypted
const mxc = await uploadFile(client, buffer, params);
return { url: mxc };
}
async function resolveMatrixClient(opts: {
@@ -318,14 +465,11 @@ async function resolveMatrixClient(opts: {
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
});
await client.startClient({
initialSyncLimit: 0,
lazyLoadMembers: true,
threadSupport: true,
});
await waitForMatrixSync({ client, timeoutMs: opts.timeoutMs });
// matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}
@@ -350,17 +494,26 @@ export async function sendMessageMatrix(
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content);
const sendContent = async (content: MatrixOutboundContent) => {
// matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const contentUri = await uploadFile(client, media.buffer, {
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,
filename: media.fileName,
});
const durationMs = await resolveMediaDurationMs({
buffer: media.buffer,
contentType: media.contentType,
fileName: media.fileName,
kind: media.kind,
});
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
@@ -375,31 +528,33 @@ export async function sendMessageMatrix(
const content = buildMediaContent({
msgtype,
body,
url: contentUri,
url: uploaded.url,
file: uploaded.file,
filename: media.fileName,
mimetype: media.contentType,
size: media.buffer.byteLength,
durationMs,
relation,
isVoice: useVoice,
imageInfo,
});
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
for (const chunk of textChunks) {
const text = chunk.trim();
if (!text) continue;
const followup = buildTextContent(text);
const followupRes = await sendContent(followup);
lastMessageId = followupRes.event_id ?? lastMessageId;
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
} else {
for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim();
if (!text) continue;
const content = buildTextContent(text, relation);
const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId;
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
}
}
@@ -409,7 +564,7 @@ export async function sendMessageMatrix(
};
} finally {
if (stopOnDone) {
client.stopClient();
client.stop();
}
}
}
@@ -433,27 +588,16 @@ export async function sendPollMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId);
const response = threadId
? await client.sendEvent(
roomId,
threadId,
M_POLL_START,
pollContent,
)
: await client.sendEvent(
roomId,
M_POLL_START,
pollContent,
);
// matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
return {
eventId: response.event_id ?? "unknown",
eventId: eventId ?? "unknown",
roomId,
};
} finally {
if (stopOnDone) {
client.stopClient();
client.stop();
}
}
}
@@ -470,10 +614,29 @@ export async function sendTypingMatrix(
});
try {
const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000;
await resolved.sendTyping(roomId, typing, resolvedTimeoutMs);
await resolved.setTyping(roomId, typing, resolvedTimeoutMs);
} finally {
if (stopOnDone) {
resolved.stopClient();
resolved.stop();
}
}
}
export async function sendReadReceiptMatrix(
roomId: string,
eventId: string,
client?: MatrixClient,
): Promise<void> {
if (!eventId?.trim()) return;
const { client: resolved, stopOnDone } = await resolveMatrixClient({
client,
});
try {
const resolvedRoom = await resolveMatrixRoomId(resolved, roomId);
await resolved.sendReadReceipt(resolvedRoom, eventId.trim());
} finally {
if (stopOnDone) {
resolved.stop();
}
}
}
@@ -502,7 +665,7 @@ export async function reactMatrixMessage(
await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction);
} finally {
if (stopOnDone) {
resolved.stopClient();
resolved.stop();
}
}
}

View File

@@ -35,8 +35,9 @@ function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Matrix requires a homeserver URL + user ID.",
"Use an access token or a password (password logs in and stores a token).",
"Matrix requires a homeserver URL.",
"Use an access token (recommended) or a password (logs in and stores a token).",
"With access token: user ID is fetched automatically.",
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
].join("\n"),
@@ -146,8 +147,8 @@ function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist"
};
}
function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
const rooms = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
@@ -155,7 +156,7 @@ function setMatrixRoomAllowlist(cfg: CoreConfig, roomKeys: string[]) {
matrix: {
...cfg.channels?.matrix,
enabled: true,
rooms,
groups,
},
},
};
@@ -180,9 +181,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
return {
channel,
configured,
statusLines: [`Matrix: ${configured ? "configured" : "needs homeserver + user id"}`],
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install matrix-js-sdk"
? "install matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
@@ -208,7 +211,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
const envUserId = process.env.MATRIX_USER_ID?.trim();
const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim();
const envPassword = process.env.MATRIX_PASSWORD?.trim();
const envReady = Boolean(envHomeserver && envUserId && (envAccessToken || envPassword));
const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword)));
if (
envReady &&
@@ -252,22 +255,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}),
).trim();
const userId = String(
await prompter.text({
message: "Matrix user ID",
initialValue: existing.userId ?? envUserId,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
return undefined;
},
}),
).trim();
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let userId = existing.userId ?? "";
if (accessToken || password) {
const keep = await prompter.confirm({
@@ -277,15 +267,17 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
if (!keep) {
accessToken = "";
password = "";
userId = "";
}
}
if (!accessToken && !password) {
// Ask auth method FIRST before asking for user ID
const authMode = (await prompter.select({
message: "Matrix auth method",
options: [
{ value: "token", label: "Access token" },
{ value: "password", label: "Password (stores token)" },
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
],
})) as "token" | "password";
@@ -296,7 +288,24 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
// With access token, we can fetch the userId automatically - don't prompt for it
// The client.ts will use whoami() to get it
userId = "";
} else {
// Password auth requires user ID upfront
userId = String(
await prompter.text({
message: "Matrix user ID",
initialValue: existing.userId ?? envUserId,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)";
return undefined;
},
}),
).trim();
password = String(
await prompter.text({
message: "Matrix password",
@@ -313,6 +322,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}),
).trim();
// Ask about E2EE encryption
const enableEncryption = await prompter.confirm({
message: "Enable end-to-end encryption (E2EE)?",
initialValue: existing.encryption ?? false,
});
next = {
...next,
channels: {
@@ -321,10 +336,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
...next.channels?.matrix,
enabled: true,
homeserver,
userId,
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},
},
};
@@ -333,13 +349,14 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
next = await promptMatrixAllowFrom({ cfg: next, prompter });
}
const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist",
currentEntries: Object.keys(next.channels?.matrix?.rooms ?? {}),
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(next.channels?.matrix?.rooms),
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
@@ -398,7 +415,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixRoomAllowlist(next, roomKeys);
next = setMatrixGroupRooms(next, roomKeys);
}
}

View File

@@ -51,12 +51,16 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: matrix-js-sdk default). */
/** Initial sync limit for startup (default: matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
/** If true, enforce allowlists for groups + DMs regardless of policy. */
allowlistOnly?: boolean;
/** Group message policy (default: allowlist). */
groupPolicy?: GroupPolicy;
/** Allowlist for group senders (user IDs or localparts). */
groupAllowFrom?: Array<string | number>;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/** How to handle thread replies (off|inbound|always). */
@@ -72,6 +76,8 @@ export type MatrixConfig = {
/** Direct message policy + allowlist overrides. */
dm?: MatrixDmConfig;
/** Room config allowlist keyed by room ID, alias, or name. */
groups?: Record<string, MatrixRoomConfig>;
/** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */
rooms?: Record<string, MatrixRoomConfig>;
/** Per-action tool gating (default: true for all). */
actions?: MatrixActionConfig;