refactor: route channel runtime via plugin api

This commit is contained in:
Peter Steinberger
2026-01-18 11:00:19 +00:00
parent 676d41d415
commit ee6e534ccb
82 changed files with 1253 additions and 3167 deletions

View File

@@ -1,10 +1,16 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import { matrixPlugin } from "./channel.js";
import { setMatrixRuntime } from "./runtime.js";
import { createPluginRuntime } from "../../../src/plugins/runtime/index.js";
describe("matrix directory", () => {
beforeEach(() => {
setMatrixRuntime(createPluginRuntime());
});
it("lists peers and groups from config", async () => {
const cfg = {
channels: {

View File

@@ -15,7 +15,7 @@ import type {
RoomTopicEventContent,
} from "matrix-js-sdk/lib/@types/state_events.js";
import { loadConfig } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import {
@@ -74,12 +74,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
cfg: loadConfig() as CoreConfig,
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,

View File

@@ -1,7 +1,7 @@
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import { loadConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
@@ -46,7 +46,7 @@ function clean(value?: string): string {
}
export function resolveMatrixConfig(
cfg: CoreConfig = loadConfig() as CoreConfig,
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveStateDir } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
homeserver: string;
@@ -16,9 +16,11 @@ const CREDENTIALS_FILENAME = "credentials.json";
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
stateDir?: string,
): string {
return path.join(stateDir, "credentials", "matrix");
const resolvedStateDir =
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return path.join(resolvedStateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {

View File

@@ -3,7 +3,8 @@ import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
@@ -40,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
? ["pnpm", "install"]
: ["npm", "install", "--omit=dev", "--silent"];
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
const result = await runCommandWithTimeout(command, {
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
cwd: root,
timeoutMs: 300_000,
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },

View File

@@ -1,8 +1,9 @@
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
import { RoomMemberEvent } from "matrix-js-sdk";
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
export function registerMatrixAutoJoin(params: {
client: MatrixClient;
@@ -10,6 +11,11 @@ export function registerMatrixAutoJoin(params: {
runtime: RuntimeEnv;
}) {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
@@ -36,7 +42,7 @@ export function registerMatrixAutoJoin(params: {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
});
}

View File

@@ -3,34 +3,9 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import {
buildMentionRegexes,
chunkMarkdownText,
createReplyDispatcherWithTyping,
danger,
dispatchReplyFromConfig,
enqueueSystemEvent,
finalizeInboundContext,
formatAgentEnvelope,
formatAllowlistMatchMeta,
getChildLogger,
hasControlCommand,
loadConfig,
logVerbose,
mergeAllowlist,
matchesMentionPatterns,
readChannelAllowFromStore,
recordSessionMetaFromInbound,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveStorePath,
resolveTextChunkLimit,
shouldHandleTextCommands,
shouldLogVerbose,
summarizeMapping,
updateLastRoute,
upsertChannelPairingRequest,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
@@ -61,6 +36,7 @@ import { deliverMatrixReplies } from "./replies.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
export type MonitorMatrixOpts = {
runtime?: RuntimeEnv;
@@ -76,7 +52,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
if (isBunRuntime()) {
throw new Error("Matrix provider requires Node (bun runtime not supported)");
}
let cfg = loadConfig() as CoreConfig;
const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return;
const runtime: RuntimeEnv = opts.runtime ?? {
@@ -207,8 +184,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
setActiveMatrixClient(client);
const mentionRegexes = buildMentionRegexes(cfg);
const logger = getChildLogger({ module: "matrix-auto-reply" });
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const logVerboseMessage = (message: string) => {
if (core.logging.shouldLogVerbose()) {
logger.debug(message);
}
};
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@@ -220,7 +202,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const allowFrom = dmConfig?.allowFrom ?? [];
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
@@ -306,22 +288,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}`;
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
return;
}
if (groupPolicy === "allowlist") {
if (!roomConfigInfo.allowlistConfigured) {
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
return;
}
if (!roomConfigInfo.config) {
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
return;
}
}
const senderName = room.getMember(senderId)?.name ?? senderId;
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
if (isDirectMessage) {
@@ -335,13 +317,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
if (!allowMatch.allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "matrix",
id: senderId,
meta: { name: senderName },
});
if (created) {
logVerbose(
logVerboseMessage(
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
);
try {
@@ -358,12 +340,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
{ client },
);
} catch (err) {
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
}
}
}
if (dmPolicy !== "pairing") {
logVerbose(
logVerboseMessage(
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
}
@@ -379,7 +361,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userName: senderName,
});
if (!userMatch.allowed) {
logVerbose(
logVerboseMessage(
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
userMatch,
)})`,
@@ -388,7 +370,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
}
if (isRoom) {
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
}
const rawBody = content.body.trim();
@@ -416,7 +398,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
maxBytes: mediaMaxBytes,
});
} catch (err) {
logVerbose(`matrix: media download failed: ${String(err)}`);
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
}
}
@@ -429,7 +411,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
text: bodyText,
mentionRegexes,
});
const allowTextCommands = shouldHandleTextCommands({
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "matrix",
});
@@ -439,14 +421,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
userId: senderId,
userName: senderName,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
});
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
if (
isRoom &&
allowTextCommands &&
core.channel.text.hasControlCommand(bodyText, cfg) &&
!commandAuthorized
) {
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom
@@ -465,7 +452,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
hasControlCommand(bodyText);
core.channel.text.hasControlCommand(bodyText);
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -482,14 +469,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const body = formatAgentEnvelope({
const body = core.channel.reply.formatAgentEnvelope({
channel: "Matrix",
from: envelopeFrom,
timestamp: event.getTs() ?? undefined,
body: textWithId,
});
const route = resolveAgentRoute({
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
peer: {
@@ -499,7 +486,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = finalizeInboundContext({
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: bodyText,
CommandBody: bodyText,
@@ -531,10 +518,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
OriginatingTo: `room:${roomId}`,
});
const storePath = resolveStorePath(cfg.session?.store, {
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
void recordSessionMetaFromInbound({
void core.channel.session.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
@@ -546,7 +533,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
if (isDirectMessage) {
await updateLastRoute({
await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "matrix",
@@ -556,10 +543,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
}
if (shouldLogVerbose()) {
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
}
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";
@@ -577,20 +562,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
};
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
});
}
const replyTarget = ctxPayload.To;
if (!replyTarget) {
runtime.error?.(danger("matrix: missing reply target"));
runtime.error?.("matrix: missing reply target");
return;
}
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
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],
@@ -604,13 +589,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
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 dispatchReplyFromConfig({
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -622,19 +607,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
markDispatchIdle();
if (!queuedFinal) return;
didSendReply = true;
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
}
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);
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
sessionKey: route.sessionKey,
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
});
}
} catch (err) {
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
runtime.error?.(`matrix handler failed: ${String(err)}`);
}
};

View File

@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-js-sdk";
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../../runtime.js";
async function fetchMatrixMediaBuffer(params: {
client: MatrixClient;
@@ -49,7 +49,12 @@ export async function downloadMatrixMedia(params: {
});
if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
headerType,
"inbound",
params.maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,

View File

@@ -1,6 +1,6 @@
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../../runtime.js";
export function resolveMentions(params: {
content: RoomMessageEventContent;
@@ -17,6 +17,9 @@ export function resolveMentions(params: {
const wasMentioned =
Boolean(mentions?.room) ||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
params.text ?? "",
params.mentionRegexes,
);
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
}

View File

@@ -1,13 +1,8 @@
import type { MatrixClient } from "matrix-js-sdk";
import {
chunkMarkdownText,
danger,
logVerbose,
type ReplyPayload,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
export async function deliverMatrixReplies(params: {
replies: ReplyPayload[];
@@ -18,6 +13,12 @@ export async function deliverMatrixReplies(params: {
replyToMode: "off" | "first" | "all";
threadId?: string;
}): Promise<void> {
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
}
};
const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false;
for (const reply of params.replies) {
@@ -27,7 +28,7 @@ export async function deliverMatrixReplies(params: {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("matrix reply missing text/media"));
params.runtime.error?.("matrix reply missing text/media");
continue;
}
const replyToIdRaw = reply.replyToId?.trim();
@@ -42,7 +43,7 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {

View File

@@ -1,5 +1,8 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("matrix-js-sdk", () => ({
EventType: {
Direct: "m.direct",
@@ -18,21 +21,33 @@ vi.mock("matrix-js-sdk", () => ({
},
}));
vi.mock("clawdbot/plugin-sdk", () => ({
loadConfig: () => ({}),
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
}),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
const resizeToJpegMock = vi.fn();
const runtimeStub = {
config: {
loadConfig: () => ({}),
},
media: {
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
mediaKindFromMime: () => "image",
isVoiceCompatibleAudio: () => false,
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
},
channel: {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
},
},
} as unknown as PluginRuntime;
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
@@ -50,11 +65,13 @@ const makeClient = () => {
describe("sendMessageMatrix media", () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
});
beforeEach(() => {
vi.clearAllMocks();
setMatrixRuntime(runtimeStub);
});
it("uploads media with url payloads", async () => {

View File

@@ -5,17 +5,8 @@ import type {
ReactionEventContent,
} from "matrix-js-sdk/lib/@types/events.js";
import {
chunkMarkdownText,
getImageMetadata,
isVoiceCompatibleAudio,
loadConfig,
loadWebMedia,
mediaKindFromMime,
type PollInput,
resolveTextChunkLimit,
resizeToJpeg,
} from "clawdbot/plugin-sdk";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
import { getActiveMatrixClient } from "./active-client.js";
import {
createMatrixClient,
@@ -29,6 +20,7 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import type { CoreConfig } from "../types.js";
const MATRIX_TEXT_LIMIT = 4000;
const getCore = () => getMatrixRuntime();
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
@@ -65,7 +57,7 @@ function ensureNodeRuntime() {
}
function resolveMediaMaxBytes(): number | undefined {
const cfg = loadConfig() as CoreConfig;
const cfg = getCore().config.loadConfig() as CoreConfig;
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
}
@@ -224,7 +216,7 @@ function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? "");
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
@@ -243,7 +235,7 @@ function resolveMatrixVoiceDecision(opts: {
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
@@ -256,19 +248,19 @@ async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null);
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await resizeToJpeg({
const thumbBuffer = await getCore().media.resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
@@ -352,10 +344,10 @@ export async function sendMessageMatrix(
});
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = loadConfig();
const textLimit = resolveTextChunkLimit(cfg, "matrix");
const cfg = getCore().config.loadConfig();
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) =>
@@ -364,7 +356,7 @@ export async function sendMessageMatrix(
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const contentUri = await uploadFile(client, media.buffer, {
contentType: media.contentType,
filename: media.fileName,