refactor: share nested account config merges

This commit is contained in:
Peter Steinberger 2026-03-22 19:52:36 +00:00
parent 6fa0027c61
commit ff941b0193
9 changed files with 202 additions and 92 deletions

View File

@ -152,4 +152,33 @@ describe("resolveIrcAccount", () => {
expect(account.passwordSource).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
it("preserves shared NickServ config when an account overrides one NickServ field", () => {
const account = resolveIrcAccount({
cfg: asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
nickserv: {
service: "NickServ",
},
accounts: {
work: {
nickserv: {
registerEmail: "work@example.com",
},
},
},
},
},
}),
accountId: "work",
});
expect(account.config.nickserv).toEqual({
service: "NickServ",
registerEmail: "work@example.com",
});
});
});

View File

@ -1,6 +1,6 @@
import { createAccountListHelpers, mergeAccountConfig } from "openclaw/plugin-sdk/account-helpers";
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveNormalizedAccountEntry } from "openclaw/plugin-sdk/account-resolution";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
@ -46,29 +46,15 @@ const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefau
createAccountListHelpers("irc", { normalizeAccountId });
export { listIrcAccountIds, resolveDefaultIrcAccountId };
function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined {
return resolveNormalizedAccountEntry(
cfg.channels?.irc?.accounts as Record<string, IrcAccountConfig> | undefined,
accountId,
normalizeAccountId,
);
}
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
const account = resolveAccountConfig(cfg, accountId) ?? {};
const merged: IrcAccountConfig = mergeAccountConfig<IrcAccountConfig>({
return resolveMergedAccountConfig<IrcAccountConfig>({
channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined,
accountConfig: account,
accounts: cfg.channels?.irc?.accounts as Record<string, Partial<IrcAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
nestedObjectKeys: ["nickserv"],
});
const baseNickServ = (cfg.channels?.irc as IrcAccountConfig | undefined)?.nickserv;
if (baseNickServ || account.nickserv) {
merged.nickserv = {
...baseNickServ,
...account.nickserv,
};
}
return merged;
}
function resolvePassword(accountId: string, merged: IrcAccountConfig) {

View File

@ -267,4 +267,47 @@ describe("resolveMatrixAccount", () => {
"@ops:example.org",
]);
});
it("preserves shared nested dm and actions config when an account overrides one field", () => {
const account = resolveMatrixAccount({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "main-token",
dm: {
enabled: true,
policy: "pairing",
},
actions: {
reactions: true,
messages: true,
},
accounts: {
ops: {
accessToken: "ops-token",
dm: {
allowFrom: ["@ops:example.org"],
},
actions: {
messages: false,
},
},
},
},
},
},
accountId: "ops",
});
expect(account.config.dm).toEqual({
enabled: true,
policy: "pairing",
allowFrom: ["@ops:example.org"],
});
expect(account.config.actions).toEqual({
reactions: true,
messages: false,
});
});
});

View File

@ -1,3 +1,4 @@
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
@ -8,26 +9,10 @@ import {
normalizeAccountId,
} from "../runtime-api.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.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;
@ -145,12 +130,13 @@ export function resolveMatrixAccountConfig(params: {
accountId?: string | null;
}): MatrixConfig {
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const accountConfig = findMatrixAccountConfig(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);
return resolveMergedAccountConfig<MatrixConfig>({
channelConfig: resolveMatrixBaseConfig(params.cfg),
accounts: params.cfg.channels?.matrix?.accounts as
| Record<string, Partial<MatrixConfig>>
| undefined,
accountId,
normalizeAccountId,
nestedObjectKeys: ["dm", "actions"],
});
}

View File

@ -87,4 +87,31 @@ describe("resolveMattermostReplyToMode", () => {
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
});
it("preserves shared commands config when an account overrides one commands field", () => {
const account = resolveMattermostAccount({
cfg: {
channels: {
mattermost: {
commands: {
native: true,
},
accounts: {
work: {
commands: {
callbackPath: "/hooks/work",
},
},
},
},
},
},
accountId: "work",
});
expect(account.config.commands).toEqual({
native: true,
callbackPath: "/hooks/work",
});
});
});

View File

@ -1,8 +1,5 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
resolveAccountEntry,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { createAccountListHelpers, type OpenClawConfig } from "../runtime-api.js";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
@ -39,39 +36,19 @@ const {
} = createAccountListHelpers("mattermost");
export { listMattermostAccountIds, resolveDefaultMattermostAccountId };
function resolveAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): MattermostAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.mattermost?.accounts, accountId);
}
function mergeMattermostAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): MattermostAccountConfig {
const account = resolveAccountConfig(cfg, accountId) ?? {};
const merged = resolveMergedAccountConfig<MattermostAccountConfig>({
return resolveMergedAccountConfig<MattermostAccountConfig>({
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
accounts: cfg.channels?.mattermost?.accounts as
| Record<string, Partial<MattermostAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["commands"],
});
// Shallow merging is fine for most keys, but `commands` should be merged
// so that account-specific overrides (callbackPath/callbackUrl) do not
// accidentally reset global settings like `native: true`.
const mergedCommands = {
...((cfg.channels?.mattermost as MattermostAccountConfig | undefined)?.commands ?? {}),
...(account.commands ?? {}),
};
if (Object.keys(mergedCommands).length > 0) {
merged.commands = mergedCommands;
}
return merged;
}
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {

View File

@ -1,7 +1,4 @@
import {
mergeAccountConfig,
resolveNormalizedAccountEntry,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
import {
createAccountListHelpers,
@ -47,27 +44,18 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
return ids;
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig | undefined {
return resolveNormalizedAccountEntry(
cfg.channels?.["nextcloud-talk"]?.accounts as
| Record<string, NextcloudTalkAccountConfig>
| undefined,
accountId,
normalizeAccountId,
);
}
function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
return mergeAccountConfig<NextcloudTalkAccountConfig>({
return resolveMergedAccountConfig<NextcloudTalkAccountConfig>({
channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined,
accountConfig: resolveAccountConfig(cfg, accountId),
accounts: cfg.channels?.["nextcloud-talk"]?.accounts as
| Record<string, Partial<NextcloudTalkAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}

View File

@ -239,6 +239,31 @@ describe("mergeAccountConfig", () => {
name: "Work",
});
});
it("deep-merges selected nested object keys", () => {
const merged = mergeAccountConfig<{
commands?: { native?: boolean; callbackPath?: string };
}>({
channelConfig: {
commands: {
native: true,
},
},
accountConfig: {
commands: {
callbackPath: "/work",
},
},
nestedObjectKeys: ["commands"],
});
expect(merged).toEqual({
commands: {
native: true,
callbackPath: "/work",
},
});
});
});
describe("resolveMergedAccountConfig", () => {
@ -286,4 +311,32 @@ describe("resolveMergedAccountConfig", () => {
name: "Router",
});
});
it("deep-merges selected nested object keys after resolving the account", () => {
const merged = resolveMergedAccountConfig<{
nickserv?: { service?: string; registerEmail?: string };
}>({
channelConfig: {
nickserv: {
service: "NickServ",
},
},
accounts: {
work: {
nickserv: {
registerEmail: "work@example.com",
},
},
},
accountId: "work",
nestedObjectKeys: ["nickserv"],
});
expect(merged).toEqual({
nickserv: {
service: "NickServ",
registerEmail: "work@example.com",
},
});
});
});

View File

@ -123,6 +123,7 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
channelConfig: TConfig | undefined;
accountConfig: Partial<TConfig> | undefined;
omitKeys?: string[];
nestedObjectKeys?: string[];
}): TConfig {
const omitKeys = new Set(["accounts", ...(params.omitKeys ?? [])]);
const base = Object.fromEntries(
@ -130,10 +131,28 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
([key]) => !omitKeys.has(key),
),
) as TConfig;
return {
const merged = {
...base,
...params.accountConfig,
};
for (const key of params.nestedObjectKeys ?? []) {
const baseValue = base[key as keyof TConfig];
const accountValue = params.accountConfig?.[key as keyof TConfig];
if (
typeof baseValue === "object" &&
baseValue != null &&
!Array.isArray(baseValue) &&
typeof accountValue === "object" &&
accountValue != null &&
!Array.isArray(accountValue)
) {
(merged as Record<string, unknown>)[key] = {
...(baseValue as Record<string, unknown>),
...(accountValue as Record<string, unknown>),
};
}
}
return merged;
}
export function resolveMergedAccountConfig<TConfig extends Record<string, unknown>>(params: {
@ -142,6 +161,7 @@ export function resolveMergedAccountConfig<TConfig extends Record<string, unknow
accountId: string;
omitKeys?: string[];
normalizeAccountId?: (accountId: string) => string;
nestedObjectKeys?: string[];
}): TConfig {
const accountConfig = params.normalizeAccountId
? resolveNormalizedAccountEntry(params.accounts, params.accountId, params.normalizeAccountId)
@ -150,5 +170,6 @@ export function resolveMergedAccountConfig<TConfig extends Record<string, unknow
channelConfig: params.channelConfig,
accountConfig,
omitKeys: params.omitKeys,
nestedObjectKeys: params.nestedObjectKeys,
});
}