Compare commits

...

11 Commits

Author SHA1 Message Date
Peter Steinberger
54147462c3 fix: harden matrix multi-account routing (#7286) (thanks @emonty) 2026-02-13 20:34:53 +01:00
Monty Taylor
d34d7219d6 fix: resolveAllowFrom uses cfg+accountId params, not account 2026-02-13 09:27:38 -06:00
Monty Taylor
70de9dd2d8 fix: use account-aware config paths in resolveDmPolicy and resolveAllowFrom 2026-02-13 09:27:38 -06:00
Monty Taylor
19ab52b05b fix: merge top-level config into per-account config so inherited settings apply 2026-02-13 09:27:38 -06:00
Monty Taylor
2093227650 chore: fix CHANGELOG.md formatting 2026-02-13 09:27:38 -06:00
Monty Taylor
b3a579fe42 fix: deep-merge nested config, prefer default account in send fallback, simplify credential filenames 2026-02-13 09:27:38 -06:00
Monty Taylor
8746e6d351 refactor: read accounts from cfg.channels.matrix.accounts directly for clarity 2026-02-13 09:27:37 -06:00
Monty Taylor
961c2033e4 fix: de-duplicate normalized account IDs and add case-insensitive config lookup to send/client 2026-02-13 09:27:37 -06:00
Monty Taylor
e4c93b334e fix: normalize account config keys for case-insensitive matching 2026-02-13 09:27:37 -06:00
Monty Taylor
44535f310c fix: normalize accountId in active-client and send/client for consistent keying 2026-02-13 09:27:37 -06:00
Monty Taylor
7f5c39a51e feat(matrix): Add multi-account support to Matrix channel
The Matrix channel previously hardcoded `listMatrixAccountIds` to always
return only `DEFAULT_ACCOUNT_ID`, ignoring any accounts configured in
`channels.matrix.accounts`. This prevented running multiple Matrix bot
accounts simultaneously.

Changes:
- Update `listMatrixAccountIds` to read from `channels.matrix.accounts`
  config, falling back to `DEFAULT_ACCOUNT_ID` for legacy single-account
  configurations
- Add `resolveMatrixConfigForAccount` to resolve config for a specific
  account ID, merging account-specific values with top-level defaults
- Update `resolveMatrixAccount` to use account-specific config when
  available
- The multi-account config structure (channels.matrix.accounts) was not
  defined in the MatrixConfig type, causing TypeScript to not recognize
  the field. Added the accounts field to properly type the multi-account
  configuration.
- Add stopSharedClientForAccount() to stop only the specific account's
  client instead of all clients when an account shuts down
- Wrap dynamic import in try/finally to prevent startup mutex deadlock
  if the import fails
- Pass accountId to resolveSharedMatrixClient(), resolveMatrixAuth(),
  and createMatrixClient() to ensure the correct account's credentials
  are used for outbound messages
- Add accountId parameter to resolveMediaMaxBytes to check account-specific
  config before falling back to top-level config
- Maintain backward compatibility with existing single-account setups

This follows the same pattern already used by the WhatsApp channel for
multi-account support.

Fixes #3165
Fixes #3085

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 09:27:36 -06:00
21 changed files with 637 additions and 127 deletions

View File

@@ -208,6 +208,10 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- 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. (#7286, #3165, #3085) Thanks @emonty.
## 2026.2.6

View File

@@ -136,6 +136,47 @@ When E2EE is enabled, the bot will request verification from your other sessions
Open Element (or another client) and approve the verification request to establish trust.
Once verified, the bot can decrypt messages in encrypted rooms.
## Multi-account
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
Each account runs as a separate Matrix user on any homeserver. Per-account config
inherits from the top-level `channels.matrix` settings and can override any option
(DM policy, groups, encryption, etc.).
```json5
{
channels: {
matrix: {
enabled: true,
dm: { policy: "pairing" },
accounts: {
assistant: {
name: "Main assistant",
homeserver: "https://matrix.example.org",
accessToken: "syt_assistant_***",
encryption: true,
},
alerts: {
name: "Alerts bot",
homeserver: "https://matrix.example.org",
accessToken: "syt_alerts_***",
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
},
},
},
},
}
```
Notes:
- Account startup is serialized to avoid race conditions with concurrent module imports.
- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
- Use `bindings[].match.accountId` to route each account to a different agent.
- Crypto state is stored per account + access token (separate key stores per account).
## Routing model
- Replies always go back to Matrix.
@@ -256,4 +297,5 @@ Provider options:
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).

View File

@@ -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);
});
});

View File

@@ -19,6 +19,7 @@ import {
} from "./group-mentions.js";
import {
listMatrixAccountIds,
resolveMatrixAccountConfig,
resolveDefaultMatrixAccountId,
resolveMatrixAccount,
type ResolvedMatrixAccount,
@@ -31,6 +32,9 @@ import { matrixOnboardingAdapter } from "./onboarding.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
let matrixStartupLock: Promise<void> = Promise.resolve();
const meta = {
id: "matrix",
label: "Matrix",
@@ -142,19 +146,28 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
configured: account.configured,
baseUrl: account.homeserver,
}),
resolveAllowFrom: ({ cfg }) =>
((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)),
resolveAllowFrom: ({ cfg, accountId }) => {
const matrixConfig = resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId });
return (matrixConfig.dm?.allowFrom ?? []).map((entry: string | number) => String(entry));
},
formatAllowFrom: ({ allowFrom }) => normalizeMatrixAllowList(allowFrom),
},
security: {
resolveDmPolicy: ({ account }) => ({
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: "channels.matrix.dm.policy",
allowFromPath: "channels.matrix.dm.allowFrom",
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
}),
resolveDmPolicy: ({ account }) => {
const accountId = account.accountId;
const prefix =
accountId && accountId !== "default"
? `channels.matrix.accounts.${accountId}.dm`
: "channels.matrix.dm";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
policyPath: `${prefix}.policy`,
allowFromPath: `${prefix}.allowFrom`,
approveHint: formatPairingApproveHint("matrix"),
normalizeEntry: (raw) => normalizeMatrixUserId(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@@ -171,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 {
@@ -278,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 }) =>
@@ -383,9 +397,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ timeoutMs, cfg }) => {
probeAccount: async ({ account, timeoutMs, cfg }) => {
try {
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
const auth = await resolveMatrixAuth({
cfg: cfg as CoreConfig,
accountId: account.accountId,
});
return await probeMatrix({
homeserver: auth.homeserver,
accessToken: auth.accessToken,
@@ -424,8 +441,32 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
baseUrl: account.homeserver,
});
ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`);
// Serialize startup: wait for any previous startup to complete import phase.
// This works around a race condition with concurrent dynamic imports.
//
// 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)
// 3. The lock only serializes the import phase, not the provider startup
const previousLock = matrixStartupLock;
let releaseLock: () => void = () => {};
matrixStartupLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
await previousLock;
// Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles.
const { monitorMatrixProvider } = await import("./matrix/index.js");
// Wrap in try/finally to ensure lock is released even if import fails.
let monitorMatrixProvider: typeof import("./matrix/index.js").monitorMatrixProvider;
try {
const module = await import("./matrix/index.js");
monitorMatrixProvider = module.monitorMatrixProvider;
} finally {
// Release lock after import completes or fails
releaseLock();
}
return monitorMatrixProvider({
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,

View 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" });
});
});

View File

@@ -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("#")) {

View File

@@ -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,

View File

@@ -1,8 +1,24 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
/** Merge account config with top-level defaults, preserving nested objects. */
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {
const merged = { ...base, ...account };
// Deep-merge known nested objects so partial overrides inherit base fields
for (const key of ["dm", "actions"] as const) {
const b = base[key];
const o = account[key];
if (typeof b === "object" && b != null && typeof o === "object" && o != null) {
(merged as Record<string, unknown>)[key] = { ...b, ...o };
}
}
// Don't propagate the accounts map into the merged per-account config
delete (merged as Record<string, unknown>).accounts;
return merged;
}
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
@@ -13,8 +29,28 @@ export type ResolvedMatrixAccount = {
config: MatrixConfig;
};
export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
return [DEFAULT_ACCOUNT_ID];
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
// Normalize and de-duplicate keys so listing and resolution use the same semantics
return [
...new Set(
Object.keys(accounts)
.filter(Boolean)
.map((id) => normalizeAccountId(id)),
),
];
}
export function listMatrixAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
// Fall back to default if no accounts configured (legacy top-level config)
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
@@ -25,20 +61,41 @@ export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): MatrixConfig | undefined {
const accounts = cfg.channels?.matrix?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
// Direct lookup first (fast path for already-normalized keys)
if (accounts[accountId]) {
return accounts[accountId] as MatrixConfig;
}
// Fall back to case-insensitive match (user may have mixed-case keys in config)
const normalized = normalizeAccountId(accountId);
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
return accounts[key] as MatrixConfig;
}
}
return undefined;
}
export function resolveMatrixAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId);
const base = params.cfg.channels?.matrix ?? {};
const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env);
const matrixBase = params.cfg.channels?.matrix ?? {};
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, process.env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken = Boolean(resolved.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth = hasUserId && hasPassword;
const stored = loadMatrixCredentials(process.env);
const stored = loadMatrixCredentials(process.env, accountId);
const hasStored =
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
@@ -58,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 }))

View File

@@ -1,3 +1,4 @@
import { normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -22,7 +23,9 @@ export async function resolveActionClient(
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient();
// Normalize accountId early to ensure consistent keying across all lookups
const accountId = normalizeAccountId(opts.accountId);
const active = getActiveMatrixClient(accountId);
if (active) {
return { client: active, stopOnDone: false };
}
@@ -31,11 +34,13 @@ export async function resolveActionClient(
const client = await resolveSharedMatrixClient({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
timeoutMs: opts.timeoutMs,
accountId,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
@@ -43,6 +48,7 @@ export async function resolveActionClient(
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId,
});
if (auth.encryption && client.crypto) {
try {

View File

@@ -57,6 +57,7 @@ export type MatrixRawEvent = {
export type MatrixActionClientOpts = {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string | null;
};
export type MatrixMessageSummary = {

View File

@@ -1,11 +1,32 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { normalizeAccountId } from "openclaw/plugin-sdk";
let activeClient: MatrixClient | null = null;
// Support multiple active clients for multi-account
const activeClients = new Map<string, MatrixClient>();
export function setActiveMatrixClient(client: MatrixClient | null): void {
activeClient = client;
export function setActiveMatrixClient(
client: MatrixClient | null,
accountId?: string | null,
): void {
const key = normalizeAccountId(accountId);
if (client) {
activeClients.set(key, client);
} else {
activeClients.delete(key);
}
}
export function getActiveMatrixClient(): MatrixClient | null {
return activeClient;
export function getActiveMatrixClient(accountId?: string | null): MatrixClient | null {
const key = normalizeAccountId(accountId);
return activeClients.get(key) ?? null;
}
export function getAnyActiveMatrixClient(): MatrixClient | null {
// Return any available client (for backward compatibility)
const first = activeClients.values().next();
return first.done ? null : first.value;
}
export function clearAllActiveMatrixClients(): void {
activeClients.clear();
}

View File

@@ -1,5 +1,14 @@
export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js";
export { isBunRuntime } from "./client/runtime.js";
export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js";
export {
resolveMatrixConfig,
resolveMatrixConfigForAccount,
resolveMatrixAuth,
} from "./client/config.js";
export { createMatrixClient } from "./client/create-client.js";
export { resolveSharedMatrixClient, waitForMatrixSync, stopSharedClient } from "./client/shared.js";
export {
resolveSharedMatrixClient,
waitForMatrixSync,
stopSharedClient,
stopSharedClientForAccount,
} from "./client/shared.js";

View File

@@ -1,4 +1,5 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -8,11 +9,49 @@ function clean(value?: string): string {
return value?.trim() ?? "";
}
export function resolveMatrixConfig(
/** Shallow-merge known nested config sub-objects so partial overrides inherit base values. */
function deepMergeConfig<T extends Record<string, unknown>>(base: T, override: Partial<T>): T {
const merged = { ...base, ...override } as Record<string, unknown>;
// Merge known nested objects (dm, actions) so partial overrides keep base fields
for (const key of ["dm", "actions"] as const) {
const b = base[key];
const o = override[key];
if (typeof b === "object" && b !== null && typeof o === "object" && o !== null) {
merged[key] = { ...(b as Record<string, unknown>), ...(o as Record<string, unknown>) };
}
}
return merged as T;
}
/**
* Resolve Matrix config for a specific account, with fallback to top-level config.
* This supports both multi-account (channels.matrix.accounts.*) and
* single-account (channels.matrix.*) configurations.
*/
export function resolveMatrixConfigForAccount(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId?: string | null,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const normalizedAccountId = normalizeAccountId(accountId);
const matrixBase = cfg.channels?.matrix ?? {};
const accounts = cfg.channels?.matrix?.accounts;
// Try to get account-specific config first (direct lookup, then case-insensitive fallback)
let accountConfig = accounts?.[normalizedAccountId];
if (!accountConfig && accounts) {
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalizedAccountId) {
accountConfig = accounts[key];
break;
}
}
}
// Deep merge: account-specific values override top-level values, preserving
// nested object inheritance (dm, actions, groups) so partial overrides work.
const matrix = accountConfig ? deepMergeConfig(matrixBase, accountConfig) : matrixBase;
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
@@ -34,13 +73,24 @@ export function resolveMatrixConfig(
};
}
/**
* Single-account function for backward compatibility - resolves default account config.
*/
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
return resolveMatrixConfigForAccount(cfg, DEFAULT_ACCOUNT_ID, env);
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
accountId?: string | null;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
const resolved = resolveMatrixConfigForAccount(cfg, params?.accountId, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
@@ -52,7 +102,8 @@ export async function resolveMatrixAuth(params?: {
touchMatrixCredentials,
} = await import("../credentials.js");
const cached = loadMatrixCredentials(env);
const accountId = params?.accountId;
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
@@ -72,13 +123,17 @@ export async function resolveMatrixAuth(params?: {
const whoami = await tempClient.getUserId();
userId = whoami;
// Save the credentials with the fetched userId
saveMatrixCredentials({
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
});
saveMatrixCredentials(
{
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
},
env,
accountId,
);
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
touchMatrixCredentials(env, accountId);
}
return {
homeserver: resolved.homeserver,
@@ -91,7 +146,7 @@ export async function resolveMatrixAuth(params?: {
}
if (cachedCredentials) {
touchMatrixCredentials(env);
touchMatrixCredentials(env, accountId);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
@@ -149,12 +204,16 @@ export async function resolveMatrixAuth(params?: {
encryption: resolved.encryption,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
});
saveMatrixCredentials(
{
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
deviceId: login.device_id,
},
env,
accountId,
);
return auth;
}

View File

@@ -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";
@@ -13,17 +14,19 @@ type SharedMatrixClientState = {
cryptoReady: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
// Support multiple accounts with separate clients
const sharedClientStates = new Map<string, SharedMatrixClientState>();
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("|");
}
@@ -57,11 +60,13 @@ async function ensureSharedClientStarted(params: {
if (params.state.started) {
return;
}
if (sharedClientStartPromise) {
await sharedClientStartPromise;
const key = params.state.key;
const existingStartPromise = sharedClientStartPromises.get(key);
if (existingStartPromise) {
await existingStartPromise;
return;
}
sharedClientStartPromise = (async () => {
const startPromise = (async () => {
const client = params.state.client;
// Initialize crypto if enabled
@@ -82,10 +87,11 @@ async function ensureSharedClientStarted(params: {
await client.start();
params.state.started = true;
})();
sharedClientStartPromises.set(key, startPromise);
try {
await sharedClientStartPromise;
await startPromise;
} finally {
sharedClientStartPromise = null;
sharedClientStartPromises.delete(key);
}
}
@@ -99,48 +105,51 @@ export async function resolveSharedMatrixClient(
accountId?: string | null;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth, params.accountId);
const accountId = normalizeAccountId(params.accountId);
const auth =
params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env, accountId }));
const key = buildSharedClientKey(auth, accountId);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
// Check if we already have a client for this key
const existingState = sharedClientStates.get(key);
if (existingState) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
state: existingState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return sharedClientState.client;
return existingState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
return pending.client;
// Check if there's a pending creation for this key
const existingPromise = sharedClientPromises.get(key);
if (existingPromise) {
const pending = await existingPromise;
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
encryption: auth.encryption,
});
}
pending.client.stop();
sharedClientState = null;
sharedClientPromise = null;
return pending.client;
}
sharedClientPromise = createSharedMatrixClient({
// Create a new client for this account
const createPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
accountId: params.accountId,
accountId,
});
sharedClientPromises.set(key, createPromise);
try {
const created = await sharedClientPromise;
sharedClientState = created;
const created = await createPromise;
sharedClientStates.set(key, created);
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
@@ -151,7 +160,7 @@ export async function resolveSharedMatrixClient(
}
return created.client;
} finally {
sharedClientPromise = null;
sharedClientPromises.delete(key);
}
}
@@ -164,9 +173,29 @@ export async function waitForMatrixSync(_params: {
// This is kept for API compatibility but is essentially a no-op now
}
export function stopSharedClient(): void {
if (sharedClientState) {
sharedClientState.client.stop();
sharedClientState = null;
export function stopSharedClient(key?: string): void {
if (key) {
// Stop a specific client
const state = sharedClientStates.get(key);
if (state) {
state.client.stop();
sharedClientStates.delete(key);
}
} else {
// Stop all clients (backward compatible behavior)
for (const state of sharedClientStates.values()) {
state.client.stop();
}
sharedClientStates.clear();
}
}
/**
* Stop the shared client for a specific account.
* Use this instead of stopSharedClient() when shutting down a single account
* to avoid stopping all accounts.
*/
export function stopSharedClientForAccount(auth: MatrixAuth, accountId?: string | null): void {
const key = buildSharedClientKey(auth, normalizeAccountId(accountId));
stopSharedClient(key);
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixStoredCredentials = {
@@ -12,7 +13,15 @@ export type MatrixStoredCredentials = {
lastUsedAt?: string;
};
const CREDENTIALS_FILENAME = "credentials.json";
function credentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (normalized === DEFAULT_ACCOUNT_ID) {
return "credentials.json";
}
// normalizeAccountId produces lowercase [a-z0-9-] strings, already filesystem-safe.
// Different raw IDs that normalize to the same value are the same logical account.
return `credentials-${normalized}.json`;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
@@ -22,15 +31,19 @@ export function resolveMatrixCredentialsDir(
return path.join(resolvedStateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const dir = resolveMatrixCredentialsDir(env);
return path.join(dir, CREDENTIALS_FILENAME);
return path.join(dir, credentialsFilename(accountId));
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env);
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credPath)) {
return null;
@@ -53,13 +66,14 @@ export function loadMatrixCredentials(
export function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const dir = resolveMatrixCredentialsDir(env);
fs.mkdirSync(dir, { recursive: true });
const credPath = resolveMatrixCredentialsPath(env);
const credPath = resolveMatrixCredentialsPath(env, accountId);
const existing = loadMatrixCredentials(env);
const existing = loadMatrixCredentials(env, accountId);
const now = new Date().toISOString();
const toSave: MatrixStoredCredentials = {
@@ -71,19 +85,25 @@ export function saveMatrixCredentials(
fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8");
}
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
const existing = loadMatrixCredentials(env);
export function touchMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const existing = loadMatrixCredentials(env, accountId);
if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env);
const credPath = resolveMatrixCredentialsPath(env, accountId);
fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
}
export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
const credPath = resolveMatrixCredentialsPath(env);
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
fs.unlinkSync(credPath);

View File

@@ -68,6 +68,7 @@ export type MatrixMonitorHandlerParams = {
roomId: string,
) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
accountId?: string | null;
};
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
@@ -93,6 +94,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
directTracker,
getRoomInfo,
getMemberDisplayName,
accountId,
} = params;
return async (roomId: string, event: MatrixRawEvent) => {
@@ -435,6 +437,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const baseRoute = core.channel.routing.resolveAgentRoute({
cfg,
channel: "matrix",
accountId,
peer: {
kind: isDirectMessage ? "direct" : "channel",
id: isDirectMessage ? senderId : roomId,

View File

@@ -3,12 +3,13 @@ import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plug
import type { CoreConfig, ReplyToMode } from "../../types.js";
import { resolveMatrixTargets } from "../../resolve-targets.js";
import { getMatrixRuntime } from "../../runtime.js";
import { resolveMatrixAccount } from "../accounts.js";
import { setActiveMatrixClient } from "../active-client.js";
import {
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
stopSharedClient,
stopSharedClientForAccount,
} from "../client.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
@@ -121,10 +122,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
return allowList.map(String);
};
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
let allowFrom: string[] = (cfg.channels?.matrix?.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (cfg.channels?.matrix?.groupAllowFrom ?? []).map(String);
let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms;
// Resolve account-specific config for multi-account support
const account = resolveMatrixAccount({ cfg, accountId: opts.accountId });
const accountConfig = account.config;
const allowlistOnly = accountConfig.allowlistOnly === true;
let allowFrom: string[] = (accountConfig.dm?.allowFrom ?? []).map(String);
let groupAllowFrom: string[] = (accountConfig.groupAllowFrom ?? []).map(String);
let roomsConfig = accountConfig.groups ?? accountConfig.rooms;
allowFrom = await resolveUserAllowlist("matrix dm allowlist", allowFrom);
groupAllowFrom = await resolveUserAllowlist("matrix group allowlist", groupAllowFrom);
@@ -213,13 +218,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
...cfg.channels?.matrix?.dm,
allowFrom,
},
...(groupAllowFrom.length > 0 ? { groupAllowFrom } : {}),
groupAllowFrom,
...(roomsConfig ? { groups: roomsConfig } : {}),
},
},
};
const auth = await resolveMatrixAuth({ cfg });
const auth = await resolveMatrixAuth({ cfg, accountId: opts.accountId });
const resolvedInitialSyncLimit =
typeof opts.initialSyncLimit === "number"
? Math.max(0, Math.floor(opts.initialSyncLimit))
@@ -234,20 +239,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
startClient: false,
accountId: opts.accountId,
});
setActiveMatrixClient(client);
setActiveMatrixClient(client, opts.accountId);
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicyRaw = accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw;
const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off";
const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound";
const dmConfig = cfg.channels?.matrix?.dm;
const replyToMode = opts.replyToMode ?? accountConfig.replyToMode ?? "off";
const threadReplies = accountConfig.threadReplies ?? "inbound";
const dmConfig = accountConfig.dm;
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
@@ -279,6 +284,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
directTracker,
getRoomInfo,
getMemberDisplayName,
accountId: opts.accountId,
});
registerMatrixMonitorEvents({
@@ -324,9 +330,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const onAbort = () => {
try {
logVerboseMessage("matrix: stopping client");
stopSharedClient();
stopSharedClientForAccount(auth, opts.accountId);
} finally {
setActiveMatrixClient(null);
setActiveMatrixClient(null, opts.accountId);
resolve();
}
};

View File

@@ -45,6 +45,7 @@ export async function sendMessageMatrix(
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {
const roomId = await resolveMatrixRoomId(client, to);
@@ -78,7 +79,7 @@ export async function sendMessageMatrix(
let lastMessageId = "";
if (opts.mediaUrl) {
const maxBytes = resolveMediaMaxBytes();
const maxBytes = resolveMediaMaxBytes(opts.accountId);
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,
@@ -166,6 +167,7 @@ export async function sendPollMatrix(
const { client, stopOnDone } = await resolveMatrixClient({
client: opts.client,
timeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
try {

View File

@@ -1,7 +1,8 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
@@ -17,8 +18,35 @@ export function ensureNodeRuntime() {
}
}
export function resolveMediaMaxBytes(): number | undefined {
/** Look up account config with case-insensitive key fallback. */
function findAccountConfig(
accounts: Record<string, unknown> | undefined,
accountId: string,
): Record<string, unknown> | undefined {
if (!accounts) return undefined;
const normalized = normalizeAccountId(accountId);
// Direct lookup first
if (accounts[normalized]) return accounts[normalized] as Record<string, unknown>;
// Case-insensitive fallback
for (const key of Object.keys(accounts)) {
if (normalizeAccountId(key) === normalized) {
return accounts[key] as Record<string, unknown>;
}
}
return undefined;
}
export function resolveMediaMaxBytes(accountId?: string): number | undefined {
const cfg = getCore().config.loadConfig() as CoreConfig;
// Check account-specific config first (case-insensitive key matching)
const accountConfig = findAccountConfig(
cfg.channels?.matrix?.accounts as Record<string, unknown> | undefined,
accountId ?? "",
);
if (typeof accountConfig?.mediaMaxMb === "number") {
return (accountConfig.mediaMaxMb as number) * 1024 * 1024;
}
// Fall back to top-level config
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
}
@@ -28,29 +56,49 @@ export function resolveMediaMaxBytes(): number | undefined {
export async function resolveMatrixClient(opts: {
client?: MatrixClient;
timeoutMs?: number;
accountId?: string;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime();
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient();
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(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 (!accountId) {
const defaultClient = getActiveMatrixClient(DEFAULT_ACCOUNT_ID);
if (defaultClient) {
return { client: defaultClient, stopOnDone: false };
}
const anyActive = getAnyActiveMatrixClient();
if (anyActive) {
return { client: anyActive, stopOnDone: false };
}
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({
timeoutMs: opts.timeoutMs,
accountId,
});
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth();
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,
});
if (auth.encryption && client.crypto) {
try {

View File

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

View File

@@ -39,11 +39,16 @@ export type MatrixActionConfig = {
channelInfo?: boolean;
};
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
export type MatrixConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start Matrix. Default: true. */
enabled?: boolean;
/** Multi-account configuration keyed by account ID. */
accounts?: Record<string, MatrixAccountConfig>;
/** Matrix homeserver URL (https://matrix.example.org). */
homeserver?: string;
/** Matrix user id (@user:server). */