mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
Matrix: simplify plugin migration plumbing
This commit is contained in:
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
2
extensions/matrix/legacy-crypto-inspector.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { MatrixLegacyCryptoInspectionResult } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
export { inspectLegacyMatrixCryptoStore } from "./src/matrix/legacy-crypto-inspector.js";
|
||||
@@ -118,4 +118,27 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.listActions!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never);
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createActionGate,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionContext,
|
||||
type ChannelMessageActionName,
|
||||
@@ -66,6 +67,9 @@ function createMatrixExposedActions(params: {
|
||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
if (requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return [];
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolveDefaultMatrixAccountId(resolvedCfg),
|
||||
|
||||
@@ -447,7 +447,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
//
|
||||
// INVARIANT: The import() below cannot hang because:
|
||||
// 1. It only loads local ESM modules with no circular awaits
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/index.js)
|
||||
// 2. Module initialization is synchronous (no top-level await in ./matrix/monitor/index.js)
|
||||
// 3. The lock only serializes the import phase, not the provider startup
|
||||
const previousLock = matrixStartupLock;
|
||||
let releaseLock: () => void = () => {};
|
||||
@@ -458,9 +458,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
|
||||
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
|
||||
// Wrap in try/finally to ensure lock is released even if import fails.
|
||||
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
|
||||
let monitorMatrixProvider: typeof import("./matrix/monitor/index.js").monitorMatrixProvider;
|
||||
try {
|
||||
const module = await import("./matrix/index.js");
|
||||
const module = await import("./matrix/monitor/index.js");
|
||||
monitorMatrixProvider = module.monitorMatrixProvider;
|
||||
} finally {
|
||||
// Release lock after import completes or fails
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/matrix";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const matrixActionSchema = z
|
||||
.object({
|
||||
@@ -81,5 +84,4 @@ export const MatrixConfigSchema = z.object({
|
||||
groups: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
rooms: z.object({}).catchall(matrixRoomSchema).optional(),
|
||||
actions: matrixActionSchema,
|
||||
register: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -110,4 +110,42 @@ describe("resolveMatrixAccount", () => {
|
||||
expect(listMatrixAccountIds(cfg)).toEqual(["main-bot", "ops"]);
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("main-bot");
|
||||
});
|
||||
|
||||
it("returns the only named account when no explicit default is set", () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
|
||||
});
|
||||
|
||||
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
alpha: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "alpha-token",
|
||||
},
|
||||
beta: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "beta-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput } from "../secret-input.js";
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import type { CoreConfig, MatrixConfig } from "../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
@@ -49,15 +49,7 @@ export function listMatrixAccountIds(cfg: CoreConfig): string[] {
|
||||
}
|
||||
|
||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||
const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
|
||||
const ids = listMatrixAccountIds(cfg);
|
||||
if (configuredDefault && ids.includes(configuredDefault)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAccount(params: {
|
||||
|
||||
@@ -36,12 +36,11 @@ vi.mock("../send.js", () => ({
|
||||
resolveMatrixRoomId: (...args: unknown[]) => resolveMatrixRoomIdMock(...args),
|
||||
}));
|
||||
|
||||
let resolveActionClient: typeof import("./client.js").resolveActionClient;
|
||||
let withResolvedActionClient: typeof import("./client.js").withResolvedActionClient;
|
||||
let withResolvedRoomAction: typeof import("./client.js").withResolvedRoomAction;
|
||||
let withStartedActionClient: typeof import("./client.js").withStartedActionClient;
|
||||
|
||||
describe("resolveActionClient", () => {
|
||||
describe("action client helpers", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
primeMatrixClientResolverMocks();
|
||||
@@ -49,12 +48,8 @@ describe("resolveActionClient", () => {
|
||||
.mockReset()
|
||||
.mockImplementation(async (_client, roomId: string) => roomId);
|
||||
|
||||
({
|
||||
resolveActionClient,
|
||||
withResolvedActionClient,
|
||||
withResolvedRoomAction,
|
||||
withStartedActionClient,
|
||||
} = await import("./client.js"));
|
||||
({ withResolvedActionClient, withResolvedRoomAction, withStartedActionClient } =
|
||||
await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -64,7 +59,7 @@ describe("resolveActionClient", () => {
|
||||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
@@ -76,56 +71,56 @@ describe("resolveActionClient", () => {
|
||||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("skips one-off room preparation when readiness is disabled", async () => {
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "none",
|
||||
});
|
||||
await withResolvedActionClient({ accountId: "default", readiness: "none" }, async () => {});
|
||||
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.start).not.toHaveBeenCalled();
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts one-off clients when started readiness is required", async () => {
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
});
|
||||
await withStartedActionClient({ accountId: "default" }, async () => {});
|
||||
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(oneOffClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveActionClient({ accountId: "default" });
|
||||
const result = await withResolvedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(result).toBe("ok");
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts active clients when started readiness is required", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveActionClient({
|
||||
accountId: "default",
|
||||
readiness: "started",
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(activeClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(activeClient.prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
expect(activeClient.stopAndPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the implicit resolved account id for active client lookup and storage", async () => {
|
||||
@@ -164,7 +159,7 @@ describe("resolveActionClient", () => {
|
||||
encryption: true,
|
||||
});
|
||||
|
||||
await resolveActionClient({});
|
||||
await withResolvedActionClient({}, async () => {});
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith(
|
||||
@@ -189,10 +184,7 @@ describe("resolveActionClient", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await resolveActionClient({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
await withResolvedActionClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
@@ -233,20 +225,6 @@ describe("resolveActionClient", () => {
|
||||
expect(oneOffClient.stopAndPersist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists one-off action clients after started wrappers complete", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
await withStartedActionClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(oneOffClient);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
expect(oneOffClient.start).toHaveBeenCalledTimes(1);
|
||||
expect(oneOffClient.stop).not.toHaveBeenCalled();
|
||||
expect(oneOffClient.stopAndPersist).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves room ids before running wrapped room actions", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
@@ -1,64 +1,15 @@
|
||||
import { resolveRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
async function ensureActionClientReadiness(
|
||||
client: MatrixActionClient["client"],
|
||||
readiness: MatrixActionClientOpts["readiness"],
|
||||
opts: { createdForOneOff: boolean },
|
||||
): Promise<void> {
|
||||
if (readiness === "started") {
|
||||
await client.start();
|
||||
return;
|
||||
}
|
||||
if (readiness === "prepared" || (!readiness && opts.createdForOneOff)) {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActionClient(
|
||||
opts: MatrixActionClientOpts = {},
|
||||
): Promise<MatrixActionClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureActionClientReadiness(client, opts.readiness, {
|
||||
createdForOneOff: context.createdForOneOff,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MatrixActionClientStopMode = "stop" | "persist";
|
||||
|
||||
export async function stopActionClient(
|
||||
resolved: MatrixActionClient,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
run: (client: MatrixActionClient["client"]) => Promise<T>,
|
||||
mode: MatrixActionClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveActionClient(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopActionClient(resolved, mode);
|
||||
}
|
||||
return await withResolvedRuntimeMatrixClient(opts, run, mode);
|
||||
}
|
||||
|
||||
export async function withStartedActionClient<T>(
|
||||
|
||||
@@ -9,23 +9,40 @@ import {
|
||||
} from "./client.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
export type ResolvedRuntimeMatrixClient = {
|
||||
type ResolvedRuntimeMatrixClient = {
|
||||
client: MatrixClient;
|
||||
stopOnDone: boolean;
|
||||
};
|
||||
|
||||
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
|
||||
|
||||
type MatrixResolvedClientHook = (
|
||||
client: MatrixClient,
|
||||
context: { createdForOneOff: boolean },
|
||||
) => Promise<void> | void;
|
||||
|
||||
export function ensureMatrixNodeRuntime() {
|
||||
async function ensureResolvedClientReadiness(params: {
|
||||
client: MatrixClient;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
createdForOneOff: boolean;
|
||||
}): Promise<void> {
|
||||
if (params.readiness === "started") {
|
||||
await params.client.start();
|
||||
return;
|
||||
}
|
||||
if (params.readiness === "prepared" || (!params.readiness && params.createdForOneOff)) {
|
||||
await params.client.prepareForOneOff();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMatrixNodeRuntime() {
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix support requires Node (bun runtime not supported)");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClient(opts: {
|
||||
async function resolveRuntimeMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
@@ -67,3 +84,58 @@ export async function resolveRuntimeMatrixClient(opts: {
|
||||
await opts.onResolved?.(client, { createdForOneOff: true });
|
||||
return { client, stopOnDone: true };
|
||||
}
|
||||
|
||||
export async function resolveRuntimeMatrixClientWithReadiness(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
}): Promise<ResolvedRuntimeMatrixClient> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
await ensureResolvedClientReadiness({
|
||||
client,
|
||||
readiness: opts.readiness,
|
||||
createdForOneOff: context.createdForOneOff,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopResolvedRuntimeMatrixClient(
|
||||
resolved: ResolvedRuntimeMatrixClient,
|
||||
mode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<void> {
|
||||
if (!resolved.stopOnDone) {
|
||||
return;
|
||||
}
|
||||
if (mode === "persist") {
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
export async function withResolvedRuntimeMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
readiness?: MatrixRuntimeClientReadiness;
|
||||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
stopMode: ResolvedRuntimeMatrixClientStopMode = "stop",
|
||||
): Promise<T> {
|
||||
const resolved = await resolveRuntimeMatrixClientWithReadiness(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
await stopResolvedRuntimeMatrixClient(resolved, stopMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import {
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client.js";
|
||||
} from "./client/config.js";
|
||||
import * as credentialsModule from "./credentials.js";
|
||||
import * as sdkModule from "./sdk.js";
|
||||
|
||||
@@ -142,12 +142,36 @@ describe("resolveMatrixConfig", () => {
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull();
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("default");
|
||||
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe(
|
||||
"default",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit defaultAccount selection when multiple named Matrix accounts exist", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
|
||||
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBeNull();
|
||||
expect(() => resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv })).toThrow(
|
||||
/channels\.matrix\.defaultAccount.*--account <id>/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects insecure public http Matrix homeservers", () => {
|
||||
expect(() => validateMatrixHomeserverUrl("http://matrix.example.org")).toThrow(
|
||||
"Matrix homeserver must use https:// unless it targets a private or loopback host",
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
|
||||
export type { MatrixAuth } from "./client/types.js";
|
||||
export { isBunRuntime } from "./client/runtime.js";
|
||||
export {
|
||||
getMatrixScopedEnvVarNames,
|
||||
hasReadyMatrixEnvAuth,
|
||||
resolveMatrixConfig,
|
||||
resolveMatrixConfigForAccount,
|
||||
resolveScopedMatrixEnvConfig,
|
||||
resolveImplicitMatrixAccountId,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
validateMatrixHomeserverUrl,
|
||||
} from "./client/config.js";
|
||||
export { createMatrixClient } from "./client/create-client.js";
|
||||
export {
|
||||
resolveSharedMatrixClient,
|
||||
waitForMatrixSync,
|
||||
stopSharedClient,
|
||||
stopSharedClientForAccount,
|
||||
} from "./client/shared.js";
|
||||
export { resolveSharedMatrixClient, stopSharedClientForAccount } from "./client/shared.js";
|
||||
|
||||
@@ -3,15 +3,13 @@ import {
|
||||
isPrivateOrLoopbackHost,
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../secret-input.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import {
|
||||
findMatrixAccountConfig,
|
||||
listNormalizedMatrixAccountIds,
|
||||
resolveMatrixBaseConfig,
|
||||
} from "../account-config.js";
|
||||
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-update.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
@@ -315,39 +313,14 @@ export function resolveMatrixConfigForAccount(
|
||||
};
|
||||
}
|
||||
|
||||
function hasMatrixAuthInputs(config: MatrixResolvedConfig): boolean {
|
||||
return Boolean(config.homeserver && (config.accessToken || (config.userId && config.password)));
|
||||
}
|
||||
|
||||
export function resolveImplicitMatrixAccountId(
|
||||
cfg: CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
_env: NodeJS.ProcessEnv = process.env,
|
||||
): string | null {
|
||||
const accountIds = listNormalizedMatrixAccountIds(cfg);
|
||||
const configuredDefault = normalizeOptionalAccountId(cfg.channels?.matrix?.defaultAccount);
|
||||
if (configuredDefault && accountIds.includes(configuredDefault)) {
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, configuredDefault, env);
|
||||
if (hasMatrixAuthInputs(resolved)) {
|
||||
return configuredDefault;
|
||||
}
|
||||
}
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readyIds = accountIds.filter((accountId) =>
|
||||
hasMatrixAuthInputs(resolveMatrixConfigForAccount(cfg, accountId, env)),
|
||||
);
|
||||
if (readyIds.length === 1) {
|
||||
return readyIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (readyIds.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
return null;
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
|
||||
}
|
||||
|
||||
export function resolveMatrixAuthContext(params?: {
|
||||
@@ -363,8 +336,12 @@ export function resolveMatrixAuthContext(params?: {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
|
||||
const effectiveAccountId =
|
||||
explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env) ?? DEFAULT_ACCOUNT_ID;
|
||||
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
|
||||
if (!effectiveAccountId) {
|
||||
throw new Error(
|
||||
'Multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. Set "channels.matrix.defaultAccount" to the intended account or pass --account <id>.',
|
||||
);
|
||||
}
|
||||
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
|
||||
|
||||
return {
|
||||
|
||||
@@ -175,15 +175,6 @@ export async function resolveSharedMatrixClient(
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatrixSync(_params: {
|
||||
client: MatrixClient;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
// matrix-js-sdk handles sync lifecycle in start() for this integration.
|
||||
// This is kept for API compatibility but is essentially a no-op now
|
||||
}
|
||||
|
||||
export function stopSharedClient(): void {
|
||||
for (const state of sharedClientStates.values()) {
|
||||
state.client.stop();
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
export { probeMatrix } from "./probe.js";
|
||||
export {
|
||||
reactMatrixMessage,
|
||||
resolveMatrixRoomId,
|
||||
sendReadReceiptMatrix,
|
||||
sendMessageMatrix,
|
||||
sendPollMatrix,
|
||||
sendTypingMatrix,
|
||||
} from "./send.js";
|
||||
export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js";
|
||||
95
extensions/matrix/src/matrix/legacy-crypto-inspector.ts
Normal file
95
extensions/matrix/src/matrix/legacy-crypto-inspector.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
|
||||
export type MatrixLegacyCryptoInspectionResult = {
|
||||
deviceId: string | null;
|
||||
roomKeyCounts: {
|
||||
total: number;
|
||||
backedUp: number;
|
||||
} | null;
|
||||
backupVersion: string | null;
|
||||
decryptionKeyBase64: string | null;
|
||||
};
|
||||
|
||||
function resolveLegacyMachineStorePath(params: {
|
||||
cryptoRootDir: string;
|
||||
deviceId: string;
|
||||
}): string | null {
|
||||
const hashedDir = path.join(
|
||||
params.cryptoRootDir,
|
||||
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
|
||||
);
|
||||
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return hashedDir;
|
||||
}
|
||||
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
|
||||
return params.cryptoRootDir;
|
||||
}
|
||||
const match = fs
|
||||
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
|
||||
);
|
||||
return match ? path.join(params.cryptoRootDir, match.name) : null;
|
||||
}
|
||||
|
||||
export async function inspectLegacyMatrixCryptoStore(params: {
|
||||
cryptoRootDir: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
log?: (message: string) => void;
|
||||
}): Promise<MatrixLegacyCryptoInspectionResult> {
|
||||
const machineStorePath = resolveLegacyMachineStorePath(params);
|
||||
if (!machineStorePath) {
|
||||
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
|
||||
}
|
||||
|
||||
const requireFn = createRequire(import.meta.url);
|
||||
await ensureMatrixCryptoRuntime({
|
||||
requireFn,
|
||||
resolveFn: requireFn.resolve.bind(requireFn),
|
||||
log: params.log,
|
||||
});
|
||||
|
||||
const { DeviceId, OlmMachine, StoreType, UserId } = requireFn(
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
) as typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
const machine = await OlmMachine.initialize(
|
||||
new UserId(params.userId),
|
||||
new DeviceId(params.deviceId),
|
||||
machineStorePath,
|
||||
"",
|
||||
StoreType.Sqlite,
|
||||
);
|
||||
|
||||
try {
|
||||
const [backupKeys, roomKeyCounts] = await Promise.all([
|
||||
machine.getBackupKeys(),
|
||||
machine.roomKeyCounts(),
|
||||
]);
|
||||
return {
|
||||
deviceId: params.deviceId,
|
||||
roomKeyCounts: roomKeyCounts
|
||||
? {
|
||||
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
|
||||
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
|
||||
}
|
||||
: null,
|
||||
backupVersion:
|
||||
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
|
||||
? backupKeys.backupVersion
|
||||
: null,
|
||||
decryptionKeyBase64:
|
||||
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
|
||||
? backupKeys.decryptionKeyBase64
|
||||
: null,
|
||||
};
|
||||
} finally {
|
||||
machine.close();
|
||||
}
|
||||
}
|
||||
@@ -29,17 +29,16 @@ vi.mock("../../runtime.js", () => ({
|
||||
getMatrixRuntime: () => getMatrixRuntimeMock(),
|
||||
}));
|
||||
|
||||
let resolveMatrixClient: typeof import("./client.js").resolveMatrixClient;
|
||||
let withResolvedMatrixClient: typeof import("./client.js").withResolvedMatrixClient;
|
||||
|
||||
describe("resolveMatrixClient", () => {
|
||||
describe("withResolvedMatrixClient", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
primeMatrixClientResolverMocks({
|
||||
resolved: {},
|
||||
});
|
||||
|
||||
({ resolveMatrixClient, withResolvedMatrixClient } = await import("./client.js"));
|
||||
({ withResolvedMatrixClient } = await import("./client.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -49,7 +48,7 @@ describe("resolveMatrixClient", () => {
|
||||
it("creates a one-off client even when OPENCLAW_GATEWAY_PORT is set", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18799");
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async () => "ok");
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("default");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledTimes(1);
|
||||
@@ -61,18 +60,23 @@ describe("resolveMatrixClient", () => {
|
||||
);
|
||||
const oneOffClient = await createMatrixClientMock.mock.results[0]?.value;
|
||||
expect(oneOffClient.prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(result.stopOnDone).toBe(true);
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
|
||||
it("reuses active monitor client when available", async () => {
|
||||
const activeClient = createMockMatrixClient();
|
||||
getActiveMatrixClientMock.mockReturnValue(activeClient);
|
||||
|
||||
const result = await resolveMatrixClient({ accountId: "default" });
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(activeClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toEqual({ client: activeClient, stopOnDone: false });
|
||||
expect(result).toBe("ok");
|
||||
expect(resolveMatrixAuthMock).not.toHaveBeenCalled();
|
||||
expect(createMatrixClientMock).not.toHaveBeenCalled();
|
||||
expect(activeClient.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the effective account id when auth resolution is implicit", async () => {
|
||||
@@ -92,7 +96,7 @@ describe("resolveMatrixClient", () => {
|
||||
encryption: false,
|
||||
});
|
||||
|
||||
await resolveMatrixClient({});
|
||||
await withResolvedMatrixClient({}, async () => {});
|
||||
|
||||
expect(getActiveMatrixClientMock).toHaveBeenCalledWith("ops");
|
||||
expect(resolveMatrixAuthMock).toHaveBeenCalledWith({
|
||||
@@ -115,10 +119,7 @@ describe("resolveMatrixClient", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await resolveMatrixClient({
|
||||
cfg: explicitCfg,
|
||||
accountId: "ops",
|
||||
});
|
||||
await withResolvedMatrixClient({ cfg: explicitCfg, accountId: "ops" }, async () => {});
|
||||
|
||||
expect(getMatrixRuntimeMock).not.toHaveBeenCalled();
|
||||
expect(resolveMatrixAuthContextMock).toHaveBeenCalledWith({
|
||||
@@ -131,19 +132,6 @@ describe("resolveMatrixClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stops one-off matrix clients after wrapped sends succeed", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
const result = await withResolvedMatrixClient({ accountId: "default" }, async (client) => {
|
||||
expect(client).toBe(oneOffClient);
|
||||
return "ok";
|
||||
});
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(oneOffClient.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still stops one-off matrix clients when wrapped sends fail", async () => {
|
||||
const oneOffClient = createMockMatrixClient();
|
||||
createMatrixClientMock.mockResolvedValue(oneOffClient);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { resolveMatrixAccountConfig } from "../accounts.js";
|
||||
import {
|
||||
resolveRuntimeMatrixClient,
|
||||
type ResolvedRuntimeMatrixClient,
|
||||
} from "../client-bootstrap.js";
|
||||
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const getCore = () => getMatrixRuntime();
|
||||
@@ -22,31 +19,6 @@ export function resolveMediaMaxBytes(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function resolveMatrixClient(opts: {
|
||||
client?: MatrixClient;
|
||||
cfg?: CoreConfig;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||
return await resolveRuntimeMatrixClient({
|
||||
client: opts.client,
|
||||
cfg: opts.cfg,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
onResolved: async (client, context) => {
|
||||
if (context.createdForOneOff) {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopResolvedMatrixClient(resolved: ResolvedRuntimeMatrixClient): void {
|
||||
if (resolved.stopOnDone) {
|
||||
resolved.client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function withResolvedMatrixClient<T>(
|
||||
opts: {
|
||||
client?: MatrixClient;
|
||||
@@ -56,10 +28,11 @@ export async function withResolvedMatrixClient<T>(
|
||||
},
|
||||
run: (client: MatrixClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const resolved = await resolveMatrixClient(opts);
|
||||
try {
|
||||
return await run(resolved.client);
|
||||
} finally {
|
||||
stopResolvedMatrixClient(resolved);
|
||||
}
|
||||
return await withResolvedRuntimeMatrixClient(
|
||||
{
|
||||
...opts,
|
||||
readiness: "prepared",
|
||||
},
|
||||
run,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,4 +356,42 @@ describe("matrix onboarding", () => {
|
||||
expect(status.statusLines).toContain("Matrix: configured");
|
||||
expect(status.selectionHint).toBe("configured");
|
||||
});
|
||||
|
||||
it("asks for defaultAccount when multiple named Matrix accounts exist", async () => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
|
||||
(homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
} as never);
|
||||
|
||||
const status = await matrixOnboardingAdapter.getStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.assistant.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.ops.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual([
|
||||
'Matrix: set "channels.matrix.defaultAccount" to select a named account',
|
||||
]);
|
||||
expect(status.selectionHint).toBe("set defaultAccount");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
promptChannelAccessConfig,
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
type RuntimeEnv,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
@@ -492,12 +493,21 @@ async function runMatrixConfigure(params: {
|
||||
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
const sdkReady = isMatrixSdkAvailable();
|
||||
if (!accountOverrides[channel] && requiresExplicitMatrixDefaultAccount(resolvedCfg)) {
|
||||
return {
|
||||
channel,
|
||||
configured: false,
|
||||
statusLines: ['Matrix: set "channels.matrix.defaultAccount" to select a named account'],
|
||||
selectionHint: !sdkReady ? "install matrix-js-sdk" : "set defaultAccount",
|
||||
};
|
||||
}
|
||||
const account = resolveMatrixAccount({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountOverrides[channel]),
|
||||
cfg: resolvedCfg,
|
||||
accountId: resolveMatrixOnboardingAccountId(resolvedCfg, accountOverrides[channel]),
|
||||
});
|
||||
const configured = account.configured;
|
||||
const sdkReady = isMatrixSdkAvailable();
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
};
|
||||
Reference in New Issue
Block a user