fix(outbound): unify resolved cfg threading across send paths (#33987)

This commit is contained in:
Josh Avant
2026-03-04 00:20:44 -06:00
committed by GitHub
parent 4d183af0cf
commit 646817dd80
62 changed files with 1780 additions and 117 deletions

View File

@@ -34,6 +34,7 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({
contentType: "image/png",
kind: "image",
});
const runtimeLoadConfigMock = vi.fn(() => ({}));
const mediaKindFromMimeMock = vi.fn(() => "image");
const isVoiceCompatibleAudioMock = vi.fn(() => false);
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
@@ -41,7 +42,7 @@ const resizeToJpegMock = vi.fn();
const runtimeStub = {
config: {
loadConfig: () => ({}),
loadConfig: runtimeLoadConfigMock,
},
media: {
loadWebMedia: loadWebMediaMock as unknown as PluginRuntime["media"]["loadWebMedia"],
@@ -65,6 +66,7 @@ const runtimeStub = {
} as unknown as PluginRuntime;
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
let resolveMediaMaxBytes: typeof import("./send/client.js").resolveMediaMaxBytes;
const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue("evt1");
@@ -80,11 +82,14 @@ const makeClient = () => {
beforeAll(async () => {
setMatrixRuntime(runtimeStub);
({ sendMessageMatrix } = await import("./send.js"));
({ resolveMediaMaxBytes } = await import("./send/client.js"));
});
describe("sendMessageMatrix media", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeLoadConfigMock.mockReset();
runtimeLoadConfigMock.mockReturnValue({});
mediaKindFromMimeMock.mockReturnValue("image");
isVoiceCompatibleAudioMock.mockReturnValue(false);
setMatrixRuntime(runtimeStub);
@@ -214,6 +219,8 @@ describe("sendMessageMatrix media", () => {
describe("sendMessageMatrix threads", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeLoadConfigMock.mockReset();
runtimeLoadConfigMock.mockReturnValue({});
setMatrixRuntime(runtimeStub);
});
@@ -240,3 +247,80 @@ describe("sendMessageMatrix threads", () => {
});
});
});
describe("sendMessageMatrix cfg threading", () => {
beforeEach(() => {
vi.clearAllMocks();
runtimeLoadConfigMock.mockReset();
runtimeLoadConfigMock.mockReturnValue({
channels: {
matrix: {
mediaMaxMb: 7,
},
},
});
setMatrixRuntime(runtimeStub);
});
it("does not call runtime loadConfig when cfg is provided", async () => {
const { client } = makeClient();
const providedCfg = {
channels: {
matrix: {
mediaMaxMb: 4,
},
},
};
await sendMessageMatrix("room:!room:example", "hello cfg", {
client,
cfg: providedCfg as any,
});
expect(runtimeLoadConfigMock).not.toHaveBeenCalled();
});
it("falls back to runtime loadConfig when cfg is omitted", async () => {
const { client } = makeClient();
await sendMessageMatrix("room:!room:example", "hello runtime", { client });
expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1);
});
});
describe("resolveMediaMaxBytes cfg threading", () => {
beforeEach(() => {
runtimeLoadConfigMock.mockReset();
runtimeLoadConfigMock.mockReturnValue({
channels: {
matrix: {
mediaMaxMb: 9,
},
},
});
setMatrixRuntime(runtimeStub);
});
it("uses provided cfg and skips runtime loadConfig", () => {
const providedCfg = {
channels: {
matrix: {
mediaMaxMb: 3,
},
},
};
const maxBytes = resolveMediaMaxBytes(undefined, providedCfg as any);
expect(maxBytes).toBe(3 * 1024 * 1024);
expect(runtimeLoadConfigMock).not.toHaveBeenCalled();
});
it("falls back to runtime loadConfig when cfg is omitted", () => {
const maxBytes = resolveMediaMaxBytes();
expect(maxBytes).toBe(9 * 1024 * 1024);
expect(runtimeLoadConfigMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -47,11 +47,12 @@ export async function sendMessageMatrix(
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
cfg: opts.cfg,
});
const cfg = opts.cfg ?? getCore().config.loadConfig();
try {
const roomId = await resolveMatrixRoomId(client, to);
return await enqueueSend(roomId, async () => {
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
@@ -81,7 +82,7 @@ export async function sendMessageMatrix(
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes(opts.accountId);
const maxBytes = resolveMediaMaxBytes(opts.accountId, cfg);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,
@@ -171,6 +172,7 @@ export async function sendPollMatrix(
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
cfg: opts.cfg,
});
try {

View File

@@ -32,19 +32,19 @@ function findAccountConfig(
return undefined;
}
export function resolveMediaMaxBytes(accountId?: string): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig;
export function resolveMediaMaxBytes(accountId?: string, cfg?: CoreConfig): number | undefined {
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
// Check account-specific config first (case-insensitive key matching)
const accountConfig = findAccountConfig(
cfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
resolvedCfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
accountId ?? "",
);
if (typeof accountConfig?.mediaMaxMb === "number") {
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
}
// Fall back to top-level config
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
if (typeof resolvedCfg.channels?.matrix?.mediaMaxMb === "number") {
return resolvedCfg.channels.matrix.mediaMaxMb * 1024 * 1024;
}
return undefined;
}
@@ -53,6 +53,7 @@ export async function resolveMatrixClient(opts: {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string;
cfg?: CoreConfig;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime();
if (opts.client) {
@@ -84,10 +85,11 @@ export async function resolveMatrixClient(opts: {
const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs,
accountId,
cfg: opts.cfg,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId });
const auth = await resolveMatrixAuth({ accountId, cfg: opts.cfg });
const client = await createPreparedMatrixClient({
auth,
timeoutMs: opts.timeoutMs,

View File

@@ -85,6 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
cfg?: import("../../types.js").CoreConfig;
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;

View File

@@ -0,0 +1,159 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
sendMessageMatrix: vi.fn(),
sendPollMatrix: vi.fn(),
}));
vi.mock("./matrix/send.js", () => ({
sendMessageMatrix: mocks.sendMessageMatrix,
sendPollMatrix: mocks.sendPollMatrix,
}));
vi.mock("./runtime.js", () => ({
getMatrixRuntime: () => ({
channel: {
text: {
chunkMarkdownText: (text: string) => [text],
},
},
}),
}));
import { matrixOutbound } from "./outbound.js";
describe("matrixOutbound cfg threading", () => {
beforeEach(() => {
mocks.sendMessageMatrix.mockReset();
mocks.sendPollMatrix.mockReset();
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "evt-1", roomId: "!room:example" });
mocks.sendPollMatrix.mockResolvedValue({ eventId: "$poll", roomId: "!room:example" });
});
it("passes resolved cfg to sendMessageMatrix for text sends", async () => {
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
await matrixOutbound.sendText!({
cfg,
to: "room:!room:example",
text: "hello",
accountId: "default",
threadId: "$thread",
replyToId: "$reply",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledWith(
"room:!room:example",
"hello",
expect.objectContaining({
cfg,
accountId: "default",
threadId: "$thread",
replyToId: "$reply",
}),
);
});
it("passes resolved cfg to sendMessageMatrix for media sends", async () => {
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
await matrixOutbound.sendMedia!({
cfg,
to: "room:!room:example",
text: "caption",
mediaUrl: "file:///tmp/cat.png",
accountId: "default",
});
expect(mocks.sendMessageMatrix).toHaveBeenCalledWith(
"room:!room:example",
"caption",
expect.objectContaining({
cfg,
mediaUrl: "file:///tmp/cat.png",
}),
);
});
it("passes resolved cfg through injected deps.sendMatrix", async () => {
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
const sendMatrix = vi.fn(async () => ({
messageId: "evt-injected",
roomId: "!room:example",
}));
await matrixOutbound.sendText!({
cfg,
to: "room:!room:example",
text: "hello via deps",
deps: { sendMatrix },
accountId: "default",
threadId: "$thread",
replyToId: "$reply",
});
expect(sendMatrix).toHaveBeenCalledWith(
"room:!room:example",
"hello via deps",
expect.objectContaining({
cfg,
accountId: "default",
threadId: "$thread",
replyToId: "$reply",
}),
);
});
it("passes resolved cfg to sendPollMatrix", async () => {
const cfg = {
channels: {
matrix: {
accessToken: "resolved-token",
},
},
} as OpenClawConfig;
await matrixOutbound.sendPoll!({
cfg,
to: "room:!room:example",
poll: {
question: "Snack?",
options: ["Pizza", "Sushi"],
},
accountId: "default",
threadId: "$thread",
});
expect(mocks.sendPollMatrix).toHaveBeenCalledWith(
"room:!room:example",
expect.objectContaining({
question: "Snack?",
options: ["Pizza", "Sushi"],
}),
expect.objectContaining({
cfg,
accountId: "default",
threadId: "$thread",
}),
);
});
});

View File

@@ -7,11 +7,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
sendText: async ({ cfg, to, text, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
cfg,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
@@ -22,11 +23,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
roomId: result.roomId,
};
},
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await send(to, text, {
cfg,
mediaUrl,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
@@ -38,10 +40,11 @@ export const matrixOutbound: ChannelOutboundAdapter = {
roomId: result.roomId,
};
},
sendPoll: async ({ to, poll, threadId, accountId }) => {
sendPoll: async ({ cfg, to, poll, threadId, accountId }) => {
const resolvedThreadId =
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
const result = await sendPollMatrix(to, poll, {
cfg,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,
});