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