Compare commits
2 Commits
main
...
matrix-wit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b92925e0 | ||
|
|
73957ca92b |
@ -17,6 +17,7 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||||
|
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||||
|
|||||||
@ -1,518 +1,15 @@
|
|||||||
import type { MatrixClient } from "matrix-bot-sdk";
|
export type {
|
||||||
|
MatrixActionClientOpts,
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
MatrixMessageSummary,
|
||||||
import type { CoreConfig } from "../types.js";
|
MatrixReactionSummary,
|
||||||
import { getActiveMatrixClient } from "./active-client.js";
|
} from "./actions/types.js";
|
||||||
import {
|
export {
|
||||||
createMatrixClient,
|
sendMatrixMessage,
|
||||||
isBunRuntime,
|
editMatrixMessage,
|
||||||
resolveMatrixAuth,
|
deleteMatrixMessage,
|
||||||
resolveSharedMatrixClient,
|
readMatrixMessages,
|
||||||
} from "./client.js";
|
} from "./actions/messages.js";
|
||||||
import {
|
export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js";
|
||||||
reactMatrixMessage,
|
export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js";
|
||||||
resolveMatrixRoomId,
|
export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js";
|
||||||
sendMessageMatrix,
|
export { reactMatrixMessage } from "./send.js";
|
||||||
} 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 };
|
|
||||||
|
|||||||
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";
|
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||||
import fs from "node:fs";
|
export { isBunRuntime } from "./client/runtime.js";
|
||||||
import os from "node:os";
|
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
|
||||||
import path from "node:path";
|
export { createMatrixClient } from "./client/create-client.js";
|
||||||
|
export {
|
||||||
import {
|
resolveSharedMatrixClient,
|
||||||
ConsoleLogger,
|
waitForMatrixSync,
|
||||||
LogService,
|
stopSharedClient,
|
||||||
MatrixClient,
|
} from "./client/shared.js";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 { format } from "node:util";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatAllowlistMatchMeta,
|
|
||||||
formatLocationText,
|
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
toLocationContext,
|
|
||||||
type NormalizedLocation,
|
|
||||||
type ReplyPayload,
|
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
import type { CoreConfig, ReplyToMode } from "../../types.js";
|
||||||
@ -24,146 +13,14 @@ import {
|
|||||||
resolveSharedMatrixClient,
|
resolveSharedMatrixClient,
|
||||||
stopSharedClient,
|
stopSharedClient,
|
||||||
} from "../client.js";
|
} 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 { registerMatrixAutoJoin } from "./auto-join.js";
|
||||||
import { createDirectRoomTracker } from "./direct.js";
|
import { createDirectRoomTracker } from "./direct.js";
|
||||||
import { downloadMatrixMedia } from "./media.js";
|
import { registerMatrixMonitorEvents } from "./events.js";
|
||||||
import { resolveMentions } from "./mentions.js";
|
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||||
import { deliverMatrixReplies } from "./replies.js";
|
import { createMatrixRoomInfoResolver } from "./room-info.js";
|
||||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
|
||||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
|
||||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.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 = {
|
export type MonitorMatrixOpts = {
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@ -343,609 +200,40 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const warnedEncryptedRooms = new Set<string>();
|
const warnedEncryptedRooms = new Set<string>();
|
||||||
const warnedCryptoMissingRooms = new Set<string>();
|
const warnedCryptoMissingRooms = new Set<string>();
|
||||||
|
|
||||||
const roomInfoCache = new Map<
|
const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client);
|
||||||
string,
|
const handleRoomMessage = createMatrixRoomMessageHandler({
|
||||||
{ name?: string; canonicalAlias?: string; altAliases: string[] }
|
client,
|
||||||
>();
|
core,
|
||||||
|
cfg,
|
||||||
// Helper to get room info
|
runtime,
|
||||||
const getRoomInfo = async (roomId: string) => {
|
logger,
|
||||||
const cached = roomInfoCache.get(roomId);
|
logVerboseMessage,
|
||||||
if (cached) return cached;
|
allowFrom,
|
||||||
let name: string | undefined;
|
roomsConfig,
|
||||||
let canonicalAlias: string | undefined;
|
mentionRegexes,
|
||||||
let altAliases: string[] = [];
|
groupPolicy,
|
||||||
try {
|
replyToMode,
|
||||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null);
|
threadReplies,
|
||||||
name = nameState?.name;
|
dmEnabled,
|
||||||
} catch { /* ignore */ }
|
dmPolicy,
|
||||||
try {
|
textLimit,
|
||||||
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
|
mediaMaxBytes,
|
||||||
canonicalAlias = aliasState?.alias;
|
startupMs,
|
||||||
altAliases = aliasState?.alt_aliases ?? [];
|
startupGraceMs,
|
||||||
} catch { /* ignore */ }
|
directTracker,
|
||||||
const info = { name, canonicalAlias, altAliases };
|
getRoomInfo,
|
||||||
roomInfoCache.set(roomId, info);
|
getMemberDisplayName,
|
||||||
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}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => {
|
registerMatrixMonitorEvents({
|
||||||
const eventId = event?.event_id ?? "unknown";
|
client,
|
||||||
const eventType = event?.type ?? "unknown";
|
auth,
|
||||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
logVerboseMessage,
|
||||||
});
|
warnedEncryptedRooms,
|
||||||
|
warnedCryptoMissingRooms,
|
||||||
// Handle failed E2EE decryption
|
logger,
|
||||||
client.on("room.failed_decryption", async (roomId: string, event: MatrixRawEvent, error: Error) => {
|
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
|
||||||
logger.warn({ roomId, eventId: event.event_id, error: error.message }, "Failed to decrypt message");
|
onRoomMessage: handleRoomMessage,
|
||||||
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"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logVerboseMessage("matrix: starting client");
|
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 {
|
import type { MatrixClient } from "matrix-bot-sdk";
|
||||||
DimensionalFileInfo,
|
|
||||||
EncryptedFile,
|
|
||||||
FileWithThumbnailInfo,
|
|
||||||
MessageEventContent,
|
|
||||||
TextualMessageEventContent,
|
|
||||||
TimedFileInfo,
|
|
||||||
VideoFileInfo,
|
|
||||||
MatrixClient,
|
|
||||||
} from "matrix-bot-sdk";
|
|
||||||
import { parseBuffer, type IFileInfo } from "music-metadata";
|
|
||||||
|
|
||||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||||
import { getMatrixRuntime } from "../runtime.js";
|
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 { 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 MATRIX_TEXT_LIMIT = 4000;
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
// Message types
|
export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js";
|
||||||
const MsgType = {
|
export { resolveMatrixRoomId } from "./send/targets.js";
|
||||||
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 async function sendMessageMatrix(
|
export async function sendMessageMatrix(
|
||||||
to: string,
|
to: string,
|
||||||
@ -551,7 +85,9 @@ export async function sendMessageMatrix(
|
|||||||
});
|
});
|
||||||
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
|
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
|
||||||
const isImage = msgtype === MsgType.Image;
|
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 [firstChunk, ...rest] = chunks;
|
||||||
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
|
||||||
const content = buildMediaContent({
|
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;
|
||||||
|
};
|
||||||
@ -5,9 +5,6 @@ function isOpenAICompletionsModel(model: Model<Api>): model is Model<"openai-com
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
export function normalizeModelCompat(model: Model<Api>): Model<Api> {
|
||||||
const isOpenAICompletionsModel = (
|
|
||||||
candidate: Model<Api>,
|
|
||||||
): candidate is Model<"openai-completions"> => candidate.api === "openai-completions";
|
|
||||||
const baseUrl = model.baseUrl ?? "";
|
const baseUrl = model.baseUrl ?? "";
|
||||||
const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
|
const isZai = model.provider === "zai" || baseUrl.includes("api.z.ai");
|
||||||
if (!isZai || !isOpenAICompletionsModel(model)) return model;
|
if (!isZai || !isOpenAICompletionsModel(model)) return model;
|
||||||
|
|||||||
@ -24,7 +24,6 @@ import {
|
|||||||
formatUpdateChannelLabel,
|
formatUpdateChannelLabel,
|
||||||
normalizeUpdateChannel,
|
normalizeUpdateChannel,
|
||||||
resolveEffectiveUpdateChannel,
|
resolveEffectiveUpdateChannel,
|
||||||
type UpdateChannel,
|
|
||||||
} from "../infra/update-channels.js";
|
} from "../infra/update-channels.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Editor, Key, matchesKey } from "@mariozechner/pi-tui";
|
import { Editor, type EditorTheme, Key, matchesKey } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
export class CustomEditor extends Editor {
|
export class CustomEditor extends Editor {
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
@ -12,22 +12,8 @@ export class CustomEditor extends Editor {
|
|||||||
onShiftTab?: () => void;
|
onShiftTab?: () => void;
|
||||||
onAltEnter?: () => void;
|
onAltEnter?: () => void;
|
||||||
|
|
||||||
constructor(tuiOrTheme: unknown, themeOrOptions?: unknown, options?: { paddingX?: number }) {
|
constructor(theme: EditorTheme) {
|
||||||
const hasTui = Boolean((tuiOrTheme as { terminal?: unknown })?.terminal);
|
super(theme);
|
||||||
const useTuiArg = hasTui && Editor.length >= 2;
|
|
||||||
|
|
||||||
if (hasTui) {
|
|
||||||
const theme = themeOrOptions;
|
|
||||||
if (useTuiArg) {
|
|
||||||
super(tuiOrTheme as never, theme as never, options as never);
|
|
||||||
} else {
|
|
||||||
super(theme as never, options as never);
|
|
||||||
this.tui = tuiOrTheme as unknown as typeof this.tui;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
super(tuiOrTheme as never, themeOrOptions as never);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(data: string): void {
|
handleInput(data: string): void {
|
||||||
|
|||||||
@ -193,7 +193,7 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
const statusContainer = new Container();
|
const statusContainer = new Container();
|
||||||
const footer = new Text("", 1, 0);
|
const footer = new Text("", 1, 0);
|
||||||
const chatLog = new ChatLog();
|
const chatLog = new ChatLog();
|
||||||
const editor = new CustomEditor(tui, editorTheme);
|
const editor = new CustomEditor(editorTheme);
|
||||||
const root = new Container();
|
const root = new Container();
|
||||||
root.addChild(header);
|
root.addChild(header);
|
||||||
root.addChild(chatLog);
|
root.addChild(chatLog);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user