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