fix(channels): thread runtime config through sends

This commit is contained in:
Peter Steinberger
2026-04-22 06:14:12 +01:00
parent e1897419de
commit 95331e5cc5
125 changed files with 1461 additions and 804 deletions

View File

@@ -30,7 +30,7 @@ type ProbeMatrix = (params: {
type SendMessageMatrix = (
to: string,
message: string,
options?: { accountId?: string },
options: { cfg: CoreConfig; accountId?: string },
) => Promise<unknown>;
export function createMatrixProbeAccount(params: {
@@ -80,13 +80,18 @@ export function createMatrixPairingText(sendMessageMatrix: SendMessageMatrix) {
notify: async ({
id,
message,
cfg,
accountId,
}: {
id: string;
message: string;
cfg: CoreConfig;
accountId?: string;
}) => {
await sendMessageMatrix(`user:${id}`, message, accountId ? { accountId } : {});
await sendMessageMatrix(`user:${id}`, message, {
cfg,
...(accountId ? { accountId } : {}),
});
},
};
}

View File

@@ -60,6 +60,7 @@ describe("matrix account path propagation", () => {
);
await pairingText.notify({
cfg: {} as never,
id: "@user:example.org",
message: pairingText.message,
accountId: "poe",
@@ -68,7 +69,7 @@ describe("matrix account path propagation", () => {
expect(sendMessageMatrixMock).toHaveBeenCalledWith(
"user:@user:example.org",
expect.any(String),
{ accountId: "poe" },
{ cfg: {}, accountId: "poe" },
);
});

View File

@@ -19,6 +19,8 @@ const {
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const TEST_CFG = {};
vi.mock("../../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
@@ -65,14 +67,20 @@ describe("action client helpers", () => {
it("stops one-off shared clients when no active monitor client is registered", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async () => "ok",
);
await expectOneOffSharedMatrixClient();
expect(result).toBe("ok");
});
it("skips one-off room preparation when readiness is disabled", async () => {
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default", readiness: "none" },
async () => {},
);
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
@@ -81,7 +89,7 @@ describe("action client helpers", () => {
});
it("starts one-off clients when started readiness is required", async () => {
await withStartedActionClient({ accountId: "default" }, async () => {});
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {});
const sharedClient = await acquireSharedMatrixClientMock.mock.results[0]?.value;
expect(sharedClient.start).toHaveBeenCalledTimes(1);
@@ -93,10 +101,13 @@ describe("action client helpers", () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
return "ok";
});
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(activeClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
@@ -107,7 +118,7 @@ describe("action client helpers", () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
await withStartedActionClient({ accountId: "default" }, async (client) => {
await withStartedActionClient({ cfg: TEST_CFG, accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
});
@@ -143,7 +154,7 @@ describe("action client helpers", () => {
encryption: true,
},
});
await withResolvedActionClient({}, async () => {});
await withResolvedActionClient({ cfg: loadConfigMock() as never }, async () => {});
await expectOneOffSharedMatrixClient({
cfg: loadConfigMock(),
@@ -172,10 +183,13 @@ describe("action client helpers", () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
expect(client).toBe(sharedClient);
return "ok";
});
const result = await withResolvedActionClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(sharedClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
@@ -186,7 +200,7 @@ describe("action client helpers", () => {
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedActionClient({ accountId: "default" }, async () => {
withResolvedActionClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
@@ -201,7 +215,7 @@ describe("action client helpers", () => {
const result = await withResolvedRoomAction(
"room:#ops:example.org",
{ accountId: "default" },
{ cfg: TEST_CFG, accountId: "default" },
async (client, resolvedRoom) => {
expect(client).toBe(sharedClient);
return resolvedRoom;

View File

@@ -4,6 +4,12 @@ import type { MatrixClient } from "../sdk.js";
import * as sendModule from "../send.js";
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
const MATRIX_ACTION_TEST_CFG = {
channels: {
matrix: {},
},
};
function installMatrixActionTestRuntime(): void {
setMatrixRuntime({
config: {
@@ -110,13 +116,15 @@ describe("matrix message actions", () => {
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
try {
const cfg = {} as never;
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
cfg,
timeoutMs: 12_345,
});
expect(result).toEqual({ eventId: "evt-edit" });
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
cfg: undefined,
cfg,
accountId: undefined,
client: undefined,
timeoutMs: 12_345,
@@ -137,7 +145,7 @@ describe("matrix message actions", () => {
"!room:example.org",
"$original",
"hello @alice:example.org and @bob:example.org",
{ client },
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });
@@ -162,7 +170,7 @@ describe("matrix message actions", () => {
"!room:example.org",
"$original",
"hello again @alice:example.org",
{ client },
{ cfg: MATRIX_ACTION_TEST_CFG, client },
);
expect(result).toEqual({ eventId: "evt-edit" });

View File

@@ -22,6 +22,9 @@ export async function sendMatrixMessage(
audioAsVoice?: boolean;
} = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
return await sendMessageMatrix(to, content, {
cfg: opts.cfg,
mediaUrl: opts.mediaUrl,
@@ -41,6 +44,9 @@ export async function editMatrixMessage(
content: string,
opts: MatrixActionClientOpts = {},
) {
if (!opts.cfg) {
throw new Error("Matrix message actions require a resolved runtime config.");
}
const trimmed = content.trim();
if (!trimmed) {
throw new Error("Matrix edit requires content");

View File

@@ -16,6 +16,16 @@ vi.mock("../../runtime.js", () => ({
}),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()),
};
});
vi.mock("./client.js", () => ({
withResolvedActionClient: (...args: unknown[]) => withResolvedActionClientMock(...args),
withStartedActionClient: (...args: unknown[]) => withStartedActionClientMock(...args),
@@ -61,7 +71,9 @@ describe("matrix verification actions", () => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications({ accountId: "ops" })).rejects.toThrow(
await expect(
listMatrixVerifications({ cfg: loadConfigMock(), accountId: "ops" }),
).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});
@@ -83,7 +95,7 @@ describe("matrix verification actions", () => {
return await run({ crypto: null });
});
await expect(listMatrixVerifications()).rejects.toThrow(
await expect(listMatrixVerifications({ cfg: loadConfigMock() })).rejects.toThrow(
"Matrix encryption is not available (enable channels.matrix.accounts.ops.encryption=true)",
);
});

View File

@@ -1,5 +1,5 @@
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { formatMatrixEncryptionUnavailableError } from "../encryption-guidance.js";
import { withResolvedActionClient, withStartedActionClient } from "./client.js";
@@ -10,7 +10,12 @@ function requireCrypto(
opts: MatrixActionClientOpts,
): NonNullable<import("../sdk.js").MatrixClient["crypto"]> {
if (!client.crypto) {
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
if (!opts.cfg) {
throw new Error(
"Matrix verification actions requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Matrix verification actions") as CoreConfig;
throw new Error(formatMatrixEncryptionUnavailableError(cfg, opts.accountId));
}
return client.crypto;

View File

@@ -14,6 +14,8 @@ const {
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const TEST_CFG = {};
vi.mock("../runtime.js", () => ({
getMatrixRuntime: () => getMatrixRuntimeMock(),
}));
@@ -56,6 +58,7 @@ describe("client bootstrap", () => {
await expect(
resolveRuntimeMatrixClientWithReadiness({
cfg: TEST_CFG,
accountId: "default",
readiness: "prepared",
}),
@@ -72,6 +75,7 @@ describe("client bootstrap", () => {
await expect(
withResolvedRuntimeMatrixClient(
{
cfg: TEST_CFG,
accountId: "default",
readiness: "started",
},

View File

@@ -1,4 +1,4 @@
import { getMatrixRuntime } from "../runtime.js";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import { isBunRuntime } from "./client/runtime.js";
@@ -71,7 +71,12 @@ async function resolveRuntimeMatrixClient(opts: {
return { client: opts.client, stopOnDone: false };
}
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
if (!opts.cfg) {
throw new Error(
"Matrix runtime client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Matrix runtime client") as CoreConfig;
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
await loadMatrixSharedClientRuntimeDeps();
const authContext = resolveMatrixAuthContext({

View File

@@ -23,6 +23,21 @@ export const matrixClientResolverMocks: MatrixClientResolverMocks = {
resolveMatrixAuthContextMock: vi.fn(),
};
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
requireRuntimeConfig: vi.fn((cfg: unknown) => {
if (cfg) {
return cfg;
}
return matrixClientResolverMocks.loadConfigMock();
}),
};
});
export function createMockMatrixClient(): MatrixClient {
return {
prepareForOneOff: vi.fn(async () => undefined),

View File

@@ -1,3 +1,4 @@
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { retryAsync } from "openclaw/plugin-sdk/retry-runtime";
import {
@@ -11,7 +12,6 @@ import {
} from "../../account-selection.js";
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
findMatrixAccountConfig,
@@ -556,8 +556,8 @@ function resolveImplicitMatrixAccountId(
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env));
}
export function resolveMatrixAuthContext(params?: {
cfg?: CoreConfig;
export function resolveMatrixAuthContext(params: {
cfg: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): {
@@ -566,7 +566,7 @@ export function resolveMatrixAuthContext(params?: {
accountId: string;
resolved: MatrixResolvedConfig;
} {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig;
const env = params?.env ?? process.env;
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
@@ -600,7 +600,16 @@ export async function resolveMatrixAuth(params?: {
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
if (!params?.cfg) {
throw new Error(
"Matrix auth requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
});
const accessToken =
(await resolveConfiguredMatrixAuthSecretInput({
cfg,

View File

@@ -5,6 +5,8 @@ const resolveMatrixAuthMock = vi.hoisted(() => vi.fn());
const resolveMatrixAuthContextMock = vi.hoisted(() => vi.fn());
const createMatrixClientMock = vi.hoisted(() => vi.fn());
const TEST_CFG = {};
vi.mock("./config.js", () => ({
resolveMatrixAuth: resolveMatrixAuthMock,
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
@@ -106,7 +108,7 @@ describe("resolveSharedMatrixClient", () => {
createMatrixClientMock.mockReset();
resolveMatrixAuthContextMock.mockImplementation(
({ accountId }: { accountId?: string | null } = {}) => ({
cfg: undefined,
cfg: TEST_CFG,
env: undefined,
accountId: accountId ?? "default",
resolved: {},
@@ -122,9 +124,17 @@ describe("resolveSharedMatrixClient", () => {
it("keeps account clients isolated when resolves are interleaved", async () => {
const { mainClient, opsClient } = primeAccountClientMocks();
const firstMain = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const firstPoe = await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
const secondMain = await resolveSharedMatrixClient({ accountId: "main" });
const firstMain = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
const firstPoe = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "ops",
startClient: false,
});
const secondMain = await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
expect(firstMain).toBe(mainClient);
expect(firstPoe).toBe(opsClient);
@@ -137,8 +147,8 @@ describe("resolveSharedMatrixClient", () => {
it("stops only the targeted account client", async () => {
const { mainAuth, mainClient, opsClient } = primeAccountClientMocks();
await resolveSharedMatrixClient({ accountId: "main", startClient: false });
await resolveSharedMatrixClient({ accountId: "ops", startClient: false });
await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main", startClient: false });
await resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "ops", startClient: false });
stopSharedClientForAccount(mainAuth);
@@ -160,9 +170,17 @@ describe("resolveSharedMatrixClient", () => {
.mockResolvedValueOnce(firstMainClient)
.mockResolvedValueOnce(secondMainClient);
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const first = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
stopSharedClientInstance(first as unknown as import("../sdk.js").MatrixClient);
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const second = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
expect(first).toBe(firstMainClient);
expect(second).toBe(secondMainClient);
@@ -175,7 +193,7 @@ describe("resolveSharedMatrixClient", () => {
const poeClient = createMockClient("ops");
resolveMatrixAuthContextMock.mockReturnValue({
cfg: undefined,
cfg: TEST_CFG,
env: undefined,
accountId: "ops",
resolved: {},
@@ -183,13 +201,13 @@ describe("resolveSharedMatrixClient", () => {
resolveMatrixAuthMock.mockResolvedValue(poeAuth);
createMatrixClientMock.mockResolvedValue(poeClient);
const first = await resolveSharedMatrixClient({ startClient: false });
const second = await resolveSharedMatrixClient({ startClient: false });
const first = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false });
const second = await resolveSharedMatrixClient({ cfg: TEST_CFG, startClient: false });
expect(first).toBe(poeClient);
expect(second).toBe(poeClient);
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
cfg: undefined,
cfg: TEST_CFG,
env: undefined,
accountId: "ops",
});
@@ -208,7 +226,11 @@ describe("resolveSharedMatrixClient", () => {
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
createMatrixClientMock.mockResolvedValue(mainClient);
const client = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
const client = await acquireSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
expect(client).toBe(mainClient);
expect(mainClient.start).not.toHaveBeenCalled();
@@ -224,8 +246,16 @@ describe("resolveSharedMatrixClient", () => {
resolveMatrixAuthMock.mockResolvedValue(mainAuth);
createMatrixClientMock.mockResolvedValue(mainClient);
const first = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
const second = await acquireSharedMatrixClient({ accountId: "main", startClient: false });
const first = await acquireSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
const second = await acquireSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
expect(first).toBe(mainClient);
expect(second).toBe(mainClient);
@@ -254,13 +284,14 @@ describe("resolveSharedMatrixClient", () => {
it("lets a later waiter abort while shared startup continues for the owner", async () => {
const { mainClient, resolveStartup } = createPendingSharedStartup();
const ownerPromise = resolveSharedMatrixClient({ accountId: "main" });
const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
await vi.waitFor(() => {
expect(mainClient.start).toHaveBeenCalledTimes(1);
});
const abortController = new AbortController();
const canceledWaiter = resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
abortSignal: abortController.signal,
});
@@ -278,13 +309,14 @@ describe("resolveSharedMatrixClient", () => {
it("keeps the shared startup lock while an aborted waiter exits early", async () => {
const { mainClient, resolveStartup } = createPendingSharedStartup();
const ownerPromise = resolveSharedMatrixClient({ accountId: "main" });
const ownerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
await vi.waitFor(() => {
expect(mainClient.start).toHaveBeenCalledTimes(1);
});
const abortController = new AbortController();
const abortedWaiter = resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
abortSignal: abortController.signal,
});
@@ -294,7 +326,7 @@ describe("resolveSharedMatrixClient", () => {
name: "AbortError",
});
const followerPromise = resolveSharedMatrixClient({ accountId: "main" });
const followerPromise = resolveSharedMatrixClient({ cfg: TEST_CFG, accountId: "main" });
expect(mainClient.start).toHaveBeenCalledTimes(1);
resolveStartup();
@@ -324,8 +356,16 @@ describe("resolveSharedMatrixClient", () => {
resolveMatrixAuthMock.mockResolvedValueOnce(firstAuth).mockResolvedValueOnce(secondAuth);
createMatrixClientMock.mockResolvedValueOnce(firstClient).mockResolvedValueOnce(secondClient);
const first = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const second = await resolveSharedMatrixClient({ accountId: "main", startClient: false });
const first = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
const second = await resolveSharedMatrixClient({
cfg: TEST_CFG,
accountId: "main",
startClient: false,
});
expect(first).toBe(firstClient);
expect(second).toBe(secondClient);

View File

@@ -155,13 +155,21 @@ async function resolveSharedMatrixClientState(
`Matrix shared client account mismatch: requested ${requestedAccountId}, auth resolved ${params.auth.accountId}`,
);
}
const authContext = params.auth
? null
: resolveMatrixAuthContext({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
});
const authContext = (() => {
if (params.auth) {
return null;
}
if (!params.cfg) {
throw new Error(
"Matrix shared client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
return resolveMatrixAuthContext({
cfg: params.cfg,
env: params.env,
accountId: params.accountId,
});
})();
const auth =
params.auth ??
(await resolveMatrixAuth({

View File

@@ -356,6 +356,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
needsRoomAliasesForConfig,
});
threadBindingManager = await createMatrixThreadBindingManager({
cfg,
accountId: effectiveAccountId,
auth,
client,

View File

@@ -33,6 +33,16 @@ const resolveMarkdownTableModeMock = vi.fn(() => "code");
const convertMarkdownTablesMock = vi.fn((text: string) => text);
const chunkMarkdownTextWithModeMock = vi.fn((text: string) => (text ? [text] : []));
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfigMock()),
};
});
vi.mock("./outbound-media-runtime.js", () => ({
loadOutboundMediaFromUrl: loadOutboundMediaFromUrlMock,
}));
@@ -179,6 +189,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
});
@@ -202,6 +213,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
});
@@ -246,6 +258,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
});
@@ -279,6 +292,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "voice caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/clip.mp3",
audioAsVoice: true,
replyToId: "$reply",
@@ -310,6 +324,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "voice caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/clip.wav",
audioAsVoice: true,
});
@@ -334,6 +349,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
});
@@ -401,6 +417,7 @@ describe("sendMessageMatrix media", () => {
await sendMessageMatrix("room:!room:example", "caption", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
mediaLocalRoots: ["/tmp/openclaw-matrix-test"],
});
@@ -426,6 +443,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "hello", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -439,6 +457,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "hello @alice:example.org", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -455,6 +474,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "hello @alice", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -470,6 +490,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "\\@alice:example.org", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -485,6 +506,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "\\@room please review", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -497,6 +519,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "@room please review", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -509,6 +532,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "caption @alice:example.org", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/photo.png",
});
@@ -528,6 +552,7 @@ describe("sendMessageMatrix mentions", () => {
await sendMessageMatrix("room:!room:example", "", {
client,
cfg: {} as never,
mediaUrl: "file:///tmp/room.png",
});
@@ -552,6 +577,7 @@ describe("sendMessageMatrix threads", () => {
await sendMessageMatrix("room:!room:example", "hello thread", {
client,
cfg: {} as never,
threadId: "$thread",
});
@@ -575,6 +601,7 @@ describe("sendMessageMatrix threads", () => {
await sendMessageMatrix("room:!room:example", "hello", {
client,
cfg: {} as never,
accountId: "ops",
});
@@ -593,6 +620,7 @@ describe("sendMessageMatrix threads", () => {
const result = await sendMessageMatrix("room:!room:example", "ignored", {
client,
cfg: {} as never,
});
expect(result).toMatchObject({
@@ -618,6 +646,7 @@ describe("sendSingleTextMessageMatrix", () => {
await expect(
sendSingleTextMessageMatrix("room:!room:example", "1234", {
client,
cfg: {} as never,
}),
).rejects.toThrow("Matrix single-message text exceeds limit");
@@ -629,6 +658,7 @@ describe("sendSingleTextMessageMatrix", () => {
await sendSingleTextMessageMatrix("room:!room:example", "@room hi @alice:example.org", {
client,
cfg: {} as never,
msgtype: "m.notice",
includeMentions: false,
});
@@ -648,6 +678,7 @@ describe("sendSingleTextMessageMatrix", () => {
await sendSingleTextMessageMatrix("room:!room:example", "done", {
client,
cfg: {} as never,
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
});
@@ -679,6 +710,7 @@ describe("editMessageMatrix mentions", () => {
"hello @alice:example.org and @bob:example.org",
{
client,
cfg: {} as never,
},
);
@@ -700,6 +732,7 @@ describe("editMessageMatrix mentions", () => {
await editMessageMatrix("room:!room:example", "$original", "hello again @alice:example.org", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -722,6 +755,7 @@ describe("editMessageMatrix mentions", () => {
await editMessageMatrix("room:!room:example", "$original", "@alice:example.org", {
client,
cfg: {} as never,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
@@ -743,6 +777,7 @@ describe("editMessageMatrix mentions", () => {
await editMessageMatrix("room:!room:example", "$original", "@room hi @alice:example.org", {
client,
cfg: {} as never,
msgtype: "m.notice",
includeMentions: false,
});
@@ -772,6 +807,7 @@ describe("editMessageMatrix mentions", () => {
await editMessageMatrix("room:!room:example", "$original", "done", {
client,
cfg: {} as never,
extraContent: { [MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY]: true },
});
@@ -801,6 +837,7 @@ describe("sendPollMatrix mentions", () => {
},
{
client,
cfg: {} as never,
},
);
@@ -841,6 +878,7 @@ describe("voteMatrixPoll", () => {
const result = await voteMatrixPoll("room:!room:example", "$poll", {
client,
cfg: {} as never,
optionIndex: 2,
});
@@ -877,6 +915,7 @@ describe("voteMatrixPoll", () => {
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
cfg: {} as never,
optionIndex: 2,
}),
).rejects.toThrow("out of range");
@@ -901,6 +940,7 @@ describe("voteMatrixPoll", () => {
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
cfg: {} as never,
optionIndexes: [1, 2],
}),
).rejects.toThrow("at most 1 selection");
@@ -916,6 +956,7 @@ describe("voteMatrixPoll", () => {
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
cfg: {} as never,
optionIndex: 1,
}),
).rejects.toThrow("is not a Matrix poll start event");
@@ -938,6 +979,7 @@ describe("voteMatrixPoll", () => {
await expect(
voteMatrixPoll("room:!room:example", "$poll", {
client,
cfg: {} as never,
optionIndex: 1,
}),
).resolves.toMatchObject({

View File

@@ -1,3 +1,4 @@
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import type { PollInput } from "../runtime-api.js";
import { getMatrixRuntime } from "../runtime.js";
@@ -135,13 +136,13 @@ async function resolvePreviousEditMentions(params: {
export function prepareMatrixSingleText(
text: string,
opts: {
cfg?: CoreConfig;
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
} = {},
},
): MatrixPreparedSingleText {
const trimmedText = text.trim();
const cfg = opts.cfg ?? getCore().config.loadConfig();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text preparation") as CoreConfig;
const tableMode =
opts.tableMode ??
getCore().channel.text.resolveMarkdownTableMode({
@@ -165,13 +166,13 @@ export function prepareMatrixSingleText(
export function chunkMatrixText(
text: string,
opts: {
cfg?: CoreConfig;
cfg: CoreConfig;
accountId?: string;
tableMode?: MarkdownTableMode;
} = {},
},
): MatrixPreparedChunkedText {
const preparedText = prepareMatrixSingleText(text, opts);
const cfg = opts.cfg ?? getCore().config.loadConfig();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix text chunking") as CoreConfig;
const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
return {
...preparedText,
@@ -186,7 +187,7 @@ export function chunkMatrixText(
export async function sendMessageMatrix(
to: string,
message: string | undefined,
opts: MatrixSendOpts = {},
opts: MatrixSendOpts,
): Promise<MatrixSendResult> {
const trimmedMessage = message?.trim() ?? "";
if (!trimmedMessage && !opts.mediaUrl) {
@@ -201,7 +202,7 @@ export async function sendMessageMatrix(
},
async (client) => {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = opts.cfg ?? getCore().config.loadConfig();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix send") as CoreConfig;
const { chunks } = chunkMatrixText(trimmedMessage, {
cfg,
accountId: opts.accountId,
@@ -330,7 +331,7 @@ export async function sendMessageMatrix(
export async function sendPollMatrix(
to: string,
poll: PollInput,
opts: MatrixSendOpts = {},
opts: MatrixSendOpts,
): Promise<{ eventId: string; roomId: string }> {
if (!poll.question?.trim()) {
throw new Error("Matrix poll requires a question");
@@ -416,7 +417,7 @@ export async function sendSingleTextMessageMatrix(
text: string,
opts: {
client?: MatrixClient;
cfg?: CoreConfig;
cfg: CoreConfig;
replyToId?: string;
threadId?: string;
accountId?: string;
@@ -425,7 +426,7 @@ export async function sendSingleTextMessageMatrix(
extraContent?: MatrixExtraContentFields;
/** When true, marks the message as a live/streaming update (MSC4357). */
live?: boolean;
} = {},
},
): Promise<MatrixSendResult> {
const { trimmedText, convertedText, singleEventLimit, fitsInSingleEvent } =
prepareMatrixSingleText(text, {
@@ -502,7 +503,7 @@ export async function editMessageMatrix(
newText: string,
opts: {
client?: MatrixClient;
cfg?: CoreConfig;
cfg: CoreConfig;
threadId?: string;
accountId?: string;
timeoutMs?: number;
@@ -511,7 +512,7 @@ export async function editMessageMatrix(
extraContent?: MatrixExtraContentFields;
/** When true, marks the edit as a live/streaming update (MSC4357). */
live?: boolean;
} = {},
},
): Promise<string> {
return await withResolvedMatrixSendClient(
{
@@ -522,7 +523,7 @@ export async function editMessageMatrix(
},
async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
const cfg = opts.cfg ?? getCore().config.loadConfig();
const cfg = requireRuntimeConfig(opts.cfg, "Matrix message edit") as CoreConfig;
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",

View File

@@ -16,6 +16,8 @@ const {
resolveMatrixAuthContextMock,
} = matrixClientResolverMocks;
const TEST_CFG = {};
vi.mock("../active-client.js", () => ({
getActiveMatrixClient: (...args: unknown[]) => getActiveMatrixClientMock(...args),
}));
@@ -56,7 +58,10 @@ describe("matrix send client helpers", () => {
it("stops one-off shared clients when no active monitor client is registered", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
const result = await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok");
const result = await withResolvedMatrixSendClient(
{ cfg: TEST_CFG, accountId: "default" },
async () => "ok",
);
await expectOneOffSharedMatrixClient({
prepareForOneOffCalls: 0,
@@ -70,10 +75,13 @@ describe("matrix send client helpers", () => {
const activeClient = createMockMatrixClient();
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await withResolvedMatrixSendClient({ accountId: "default" }, async (client) => {
expect(client).toBe(activeClient);
return "ok";
});
const result = await withResolvedMatrixSendClient(
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(activeClient);
return "ok";
},
);
expect(result).toBe("ok");
expect(acquireSharedMatrixClientMock).not.toHaveBeenCalled();
@@ -89,7 +97,7 @@ describe("matrix send client helpers", () => {
accountId: "ops",
resolved: {},
});
await withResolvedMatrixSendClient({}, async () => {});
await withResolvedMatrixSendClient({ cfg: TEST_CFG }, async () => {});
await expectOneOffSharedMatrixClient({
accountId: "ops",
@@ -121,7 +129,7 @@ describe("matrix send client helpers", () => {
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await expect(
withResolvedMatrixSendClient({ accountId: "default" }, async () => {
withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
@@ -133,7 +141,7 @@ describe("matrix send client helpers", () => {
const sharedClient = createMockMatrixClient();
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
await withResolvedMatrixSendClient({ accountId: "default" }, async () => "ok");
await withResolvedMatrixSendClient({ cfg: TEST_CFG, accountId: "default" }, async () => "ok");
expect(sharedClient.start).toHaveBeenCalledTimes(1);
expect(sharedClient.prepareForOneOff).not.toHaveBeenCalled();
@@ -141,7 +149,7 @@ describe("matrix send client helpers", () => {
it("keeps one-off control clients lightweight when no active monitor client is registered", async () => {
const result = await withResolvedMatrixControlClient(
{ accountId: "default" },
{ cfg: TEST_CFG, accountId: "default" },
async () => "ok",
);
@@ -158,7 +166,7 @@ describe("matrix send client helpers", () => {
getActiveMatrixClientMock.mockReturnValue(activeClient);
const result = await withResolvedMatrixControlClient(
{ accountId: "default" },
{ cfg: TEST_CFG, accountId: "default" },
async (client) => {
expect(client).toBe(activeClient);
return "ok";

View File

@@ -1,10 +1,8 @@
import { getMatrixRuntime } from "../../runtime.js";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/config-runtime";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import type { MatrixClient } from "../sdk.js";
const getCore = () => getMatrixRuntime();
type MatrixSendClientRuntime = Pick<
typeof import("../client-bootstrap.js"),
"withResolvedRuntimeMatrixClient"
@@ -21,7 +19,12 @@ export function resolveMediaMaxBytes(
accountId?: string | null,
cfg?: CoreConfig,
): number | undefined {
const resolvedCfg = cfg ?? (getCore().config.loadConfig() as CoreConfig);
if (!cfg) {
throw new Error(
"Matrix media limits requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const resolvedCfg = requireRuntimeConfig(cfg, "Matrix media limits") as CoreConfig;
const matrixCfg = resolveMatrixAccountConfig({ cfg: resolvedCfg, accountId });
const mediaMaxMb = typeof matrixCfg.mediaMaxMb === "number" ? matrixCfg.mediaMaxMb : undefined;
if (typeof mediaMaxMb === "number") {

View File

@@ -89,8 +89,8 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
cfg: CoreConfig;
client?: import("../sdk.js").MatrixClient;
cfg?: CoreConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];

View File

@@ -74,6 +74,7 @@ describe("matrix thread bindings", () => {
} = {},
) {
return createMatrixThreadBindingManager({
cfg: {},
accountId,
auth: params.auth ?? auth,
client: matrixClient,
@@ -170,6 +171,7 @@ describe("matrix thread bindings", () => {
it("creates child Matrix thread bindings from a top-level room context", async () => {
await createMatrixThreadBindingManager({
cfg: {},
accountId,
auth,
client: matrixClient,
@@ -193,6 +195,7 @@ describe("matrix thread bindings", () => {
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro root", {
cfg: {},
client: {},
accountId: "ops",
});
@@ -214,6 +217,7 @@ describe("matrix thread bindings", () => {
});
expect(sendMessageMatrixMock).toHaveBeenCalledWith("room:!room:example", "intro thread", {
cfg: {},
client: {},
accountId: "ops",
threadId: "$thread",
@@ -236,6 +240,7 @@ describe("matrix thread bindings", () => {
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
try {
await createMatrixThreadBindingManager({
cfg: {},
accountId: "ops",
auth,
client: {} as never,
@@ -280,6 +285,7 @@ describe("matrix thread bindings", () => {
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
try {
await createMatrixThreadBindingManager({
cfg: {},
accountId: "ops",
auth,
client: {} as never,
@@ -333,6 +339,7 @@ describe("matrix thread bindings", () => {
const logVerboseMessage = vi.fn();
try {
await createMatrixThreadBindingManager({
cfg: {},
accountId: "ops",
auth,
client: {} as never,
@@ -387,6 +394,7 @@ describe("matrix thread bindings", () => {
it("sends threaded farewell messages when bindings are unbound", async () => {
await createMatrixThreadBindingManager({
cfg: {},
accountId: "ops",
auth,
client: {} as never,
@@ -420,6 +428,7 @@ describe("matrix thread bindings", () => {
"room:!room:example",
expect.stringContaining("Session ended automatically"),
expect.objectContaining({
cfg: {},
accountId: "ops",
threadId: "$thread",
}),
@@ -569,6 +578,7 @@ describe("matrix thread bindings", () => {
vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z"));
try {
const manager = await createMatrixThreadBindingManager({
cfg: {},
accountId: "ops",
auth,
client: {} as never,

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -146,6 +147,7 @@ function buildMatrixBindingIntroText(params: {
}
async function sendBindingMessage(params: {
cfg: OpenClawConfig;
client: MatrixClient;
accountId: string;
roomId: string;
@@ -157,6 +159,7 @@ async function sendBindingMessage(params: {
return null;
}
const result = await sendMessageMatrix(`room:${params.roomId}`, trimmed, {
cfg: params.cfg,
client: params.client,
accountId: params.accountId,
...(params.threadId ? { threadId: params.threadId } : {}),
@@ -165,6 +168,7 @@ async function sendBindingMessage(params: {
}
async function sendFarewellMessage(params: {
cfg: OpenClawConfig;
client: MatrixClient;
accountId: string;
record: MatrixThreadBindingRecord;
@@ -185,6 +189,7 @@ async function sendFarewellMessage(params: {
maxAgeMs,
});
await sendBindingMessage({
cfg: params.cfg,
client: params.client,
accountId: params.accountId,
roomId,
@@ -198,6 +203,7 @@ async function sendFarewellMessage(params: {
}
export async function createMatrixThreadBindingManager(params: {
cfg: OpenClawConfig;
accountId: string;
auth: MatrixAuth;
client: MatrixClient;
@@ -387,6 +393,7 @@ export async function createMatrixThreadBindingManager(params: {
await Promise.all(
removed.map(async (record) => {
await sendFarewellMessage({
cfg: params.cfg,
client: params.client,
accountId: params.accountId,
record,
@@ -429,6 +436,7 @@ export async function createMatrixThreadBindingManager(params: {
if (input.placement === "child") {
const roomId = parentConversationId || conversationId;
const rootEventId = await sendBindingMessage({
cfg: params.cfg,
client: params.client,
accountId: params.accountId,
roomId,
@@ -468,6 +476,7 @@ export async function createMatrixThreadBindingManager(params: {
? boundConversationId
: undefined;
await sendBindingMessage({
cfg: params.cfg,
client: params.client,
accountId: params.accountId,
roomId,