mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix: harden matrix multi-account routing (#7286) (thanks @emonty)
This commit is contained in:
@@ -239,7 +239,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
|
||||
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
|
||||
- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123.
|
||||
- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#3165, #3085) Thanks @emonty.
|
||||
- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
|
||||
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
||||
ConsoleLogger: class {
|
||||
trace = vi.fn();
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
MatrixClient: class {},
|
||||
LogService: {
|
||||
setLogger: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
SimpleFsStorageProvider: class {},
|
||||
RustSdkCryptoStorageProvider: class {},
|
||||
}));
|
||||
|
||||
describe("matrix directory", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
@@ -61,4 +80,65 @@ describe("matrix directory", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves replyToMode from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
replyToMode: "off",
|
||||
accounts: {
|
||||
Assistant: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.threading?.resolveReplyToMode).toBeTruthy();
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
matrixPlugin.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("resolves group mention policy from account config", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: true },
|
||||
},
|
||||
accounts: {
|
||||
Assistant: {
|
||||
groups: {
|
||||
"!room:example.org": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
|
||||
expect(matrixPlugin.groups.resolveRequireMention({ cfg, groupId: "!room:example.org" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
matrixPlugin.groups.resolveRequireMention({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
groupId: "!room:example.org",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./group-mentions.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
resolveMatrixAccountConfig,
|
||||
resolveDefaultMatrixAccountId,
|
||||
resolveMatrixAccount,
|
||||
type ResolvedMatrixAccount,
|
||||
@@ -146,8 +147,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
baseUrl: account.homeserver,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
return (account.config.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
|
||||
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
|
||||
},
|
||||
@@ -183,7 +184,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off",
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off",
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
const currentTarget = context.To;
|
||||
return {
|
||||
@@ -290,10 +292,10 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
.map((id) => ({ kind: "group", id }) as const);
|
||||
return ids;
|
||||
},
|
||||
listPeersLive: async ({ cfg, query, limit }) =>
|
||||
listMatrixDirectoryPeersLive({ cfg, query, limit }),
|
||||
listGroupsLive: async ({ cfg, query, limit }) =>
|
||||
listMatrixDirectoryGroupsLive({ cfg, query, limit }),
|
||||
listPeersLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryPeersLive({ cfg, accountId, query, limit }),
|
||||
listGroupsLive: async ({ cfg, accountId, query, limit }) =>
|
||||
listMatrixDirectoryGroupsLive({ cfg, accountId, query, limit }),
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, inputs, kind, runtime }) =>
|
||||
|
||||
54
extensions/matrix/src/directory-live.test.ts
Normal file
54
extensions/matrix/src/directory-live.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
|
||||
vi.mock("./matrix/client.js", () => ({
|
||||
resolveMatrixAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("matrix directory live", () => {
|
||||
const cfg = { channels: { matrix: {} } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveMatrixAuth).mockReset();
|
||||
vi.mocked(resolveMatrixAuth).mockResolvedValue({
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "test-token",
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ results: [] }),
|
||||
text: async () => "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("passes accountId to peer directory auth resolution", async () => {
|
||||
await listMatrixDirectoryPeersLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "alice",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
|
||||
it("passes accountId to group directory auth resolution", async () => {
|
||||
await listMatrixDirectoryGroupsLive({
|
||||
cfg,
|
||||
accountId: "assistant",
|
||||
query: "!room:example.org",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(resolveMatrixAuth).toHaveBeenCalledWith({ cfg, accountId: "assistant" });
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ function normalizeQuery(value?: string | null): string {
|
||||
|
||||
export async function listMatrixDirectoryPeersLive(params: {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
@@ -57,7 +58,7 @@ export async function listMatrixDirectoryPeersLive(params: {
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
|
||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||
homeserver: auth.homeserver,
|
||||
accessToken: auth.accessToken,
|
||||
@@ -122,6 +123,7 @@ async function fetchMatrixRoomName(
|
||||
|
||||
export async function listMatrixDirectoryGroupsLive(params: {
|
||||
cfg: unknown;
|
||||
accountId?: string | null;
|
||||
query?: string | null;
|
||||
limit?: number | null;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
@@ -129,7 +131,7 @@ export async function listMatrixDirectoryGroupsLive(params: {
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never, accountId: params.accountId });
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||
|
||||
if (query.startsWith("#")) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { resolveMatrixAccountConfig } from "./matrix/accounts.js";
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
|
||||
export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
@@ -18,8 +19,9 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
@@ -56,8 +58,9 @@ export function resolveMatrixGroupToolPolicy(
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const matrixConfig = resolveMatrixAccountConfig({ cfg, accountId: params.accountId });
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
rooms: matrixConfig.groups ?? matrixConfig.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
|
||||
@@ -86,16 +86,7 @@ export function resolveMatrixAccount(params: {
|
||||
}): ResolvedMatrixAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
|
||||
// Check if this account exists in accounts structure
|
||||
const accountConfig = resolveAccountConfig(params.cfg, accountId);
|
||||
|
||||
// Merge account-specific config with top-level defaults so settings like
|
||||
// blockStreaming, groupPolicy, etc. inherit from channels.matrix when not
|
||||
// overridden per account.
|
||||
const base: MatrixConfig = accountConfig
|
||||
? mergeAccountConfig(matrixBase, accountConfig)
|
||||
: matrixBase;
|
||||
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
|
||||
const enabled = base.enabled !== false && matrixBase.enabled !== false;
|
||||
|
||||
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
|
||||
@@ -124,6 +115,21 @@ export function resolveMatrixAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMatrixAccountConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): MatrixConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const matrixBase = params.cfg.channels?.matrix ?? {};
|
||||
const accountConfig = resolveAccountConfig(params.cfg, accountId);
|
||||
if (!accountConfig) {
|
||||
return matrixBase;
|
||||
}
|
||||
// Merge account-specific config with top-level defaults so settings like
|
||||
// groupPolicy and blockStreaming inherit when not overridden.
|
||||
return mergeAccountConfig(matrixBase, accountConfig);
|
||||
}
|
||||
|
||||
export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] {
|
||||
return listMatrixAccountIds(cfg)
|
||||
.map((accountId) => resolveMatrixAccount({ cfg, accountId }))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { LogService } from "@vector-im/matrix-bot-sdk";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import type { MatrixAuth } from "./types.js";
|
||||
import { resolveMatrixAuth } from "./config.js";
|
||||
@@ -19,12 +20,13 @@ const sharedClientPromises = new Map<string, Promise<SharedMatrixClientState>>()
|
||||
const sharedClientStartPromises = new Map<string, Promise<void>>();
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
normalizedAccountId || DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
@@ -103,10 +105,10 @@ export async function resolveSharedMatrixClient(
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const auth =
|
||||
params.auth ??
|
||||
(await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId: params.accountId }));
|
||||
const key = buildSharedClientKey(auth, params.accountId);
|
||||
params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const shouldStart = params.startClient !== false;
|
||||
|
||||
// Check if we already have a client for this key
|
||||
@@ -142,7 +144,7 @@ export async function resolveSharedMatrixClient(
|
||||
const createPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
});
|
||||
sharedClientPromises.set(key, createPromise);
|
||||
try {
|
||||
@@ -194,6 +196,6 @@ export function stopSharedClient(key?: string): void {
|
||||
* to avoid stopping all accounts.
|
||||
*/
|
||||
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
|
||||
const key = buildSharedClientKey(auth, accountId);
|
||||
const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
|
||||
stopSharedClient(key);
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
...cfg.channels?.matrix?.dm,
|
||||
allowFrom,
|
||||
},
|
||||
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
|
||||
groupAllowFrom,
|
||||
...(roomsConfig ? { groups: roomsConfig } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -62,14 +62,18 @@ export async function resolveMatrixClient(opts: {
|
||||
if (opts.client) {
|
||||
return { client: opts.client, stopOnDone: false };
|
||||
}
|
||||
const accountId =
|
||||
typeof opts.accountId === "string" && opts.accountId.trim().length > 0
|
||||
? normalizeAccountId(opts.accountId)
|
||||
: undefined;
|
||||
// Try to get the client for the specific account
|
||||
const active = getActiveMatrixClient(opts.accountId);
|
||||
const active = getActiveMatrixClient(accountId);
|
||||
if (active) {
|
||||
return { client: active, stopOnDone: false };
|
||||
}
|
||||
// When no account is specified, try the default account first; only fall back to
|
||||
// any active client as a last resort (prevents sending from an arbitrary account).
|
||||
if (!opts.accountId) {
|
||||
if (!accountId) {
|
||||
const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID);
|
||||
if (defaultClient) {
|
||||
return { client: defaultClient, stopOnDone: false };
|
||||
@@ -83,18 +87,18 @@ export async function resolveMatrixClient(opts: {
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
accountId,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({ accountId: opts.accountId });
|
||||
const auth = await resolveMatrixAuth({ accountId });
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
accessToken: auth.accessToken,
|
||||
encryption: auth.encryption,
|
||||
localTimeoutMs: opts.timeoutMs,
|
||||
accountId: opts.accountId,
|
||||
accountId,
|
||||
});
|
||||
if (auth.encryption && client.crypto) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user