diff --git a/extensions/irc/src/accounts.test.ts b/extensions/irc/src/accounts.test.ts index 1d8ab275af0..676ead48181 100644 --- a/extensions/irc/src/accounts.test.ts +++ b/extensions/irc/src/accounts.test.ts @@ -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", + }); + }); }); diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts index d33290d0310..71630eb6158 100644 --- a/extensions/irc/src/accounts.ts +++ b/extensions/irc/src/accounts.ts @@ -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 | undefined, - accountId, - normalizeAccountId, - ); -} - function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig { - const account = resolveAccountConfig(cfg, accountId) ?? {}; - const merged: IrcAccountConfig = mergeAccountConfig({ + return resolveMergedAccountConfig({ channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined, - accountConfig: account, + accounts: cfg.channels?.irc?.accounts as Record> | 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) { diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 9b098f47b87..c004da93d82 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -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, + }); + }); }); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index 8e0fdaa5a5a..e63b0402a61 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -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)[key] = { ...b, ...o }; - } - } - // Don't propagate the accounts map into the merged per-account config - delete (merged as Record).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({ + channelConfig: resolveMatrixBaseConfig(params.cfg), + accounts: params.cfg.channels?.matrix?.accounts as + | Record> + | undefined, + accountId, + normalizeAccountId, + nestedObjectKeys: ["dm", "actions"], + }); } diff --git a/extensions/mattermost/src/mattermost/accounts.test.ts b/extensions/mattermost/src/mattermost/accounts.test.ts index 097836b8a68..00f18cc6864 100644 --- a/extensions/mattermost/src/mattermost/accounts.test.ts +++ b/extensions/mattermost/src/mattermost/accounts.test.ts @@ -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", + }); + }); }); diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index c919eeeaa8f..c4b97c8f650 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -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({ + return resolveMergedAccountConfig({ channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined, accounts: cfg.channels?.mattermost?.accounts as | Record> | 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 { diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index 27e3178c84c..f3721ebf719 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -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 - | undefined, - accountId, - normalizeAccountId, - ); -} - function mergeNextcloudTalkAccountConfig( cfg: CoreConfig, accountId: string, ): NextcloudTalkAccountConfig { - return mergeAccountConfig({ + return resolveMergedAccountConfig({ channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined, - accountConfig: resolveAccountConfig(cfg, accountId), + accounts: cfg.channels?.["nextcloud-talk"]?.accounts as + | Record> + | undefined, + accountId, omitKeys: ["defaultAccount"], + normalizeAccountId, }); } diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 8ee5805a93d..02d9d26c503 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -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", + }, + }); + }); }); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index fde09b2ca37..a137f0baf4f 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -123,6 +123,7 @@ export function mergeAccountConfig>(para channelConfig: TConfig | undefined; accountConfig: Partial | undefined; omitKeys?: string[]; + nestedObjectKeys?: string[]; }): TConfig { const omitKeys = new Set(["accounts", ...(params.omitKeys ?? [])]); const base = Object.fromEntries( @@ -130,10 +131,28 @@ export function mergeAccountConfig>(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)[key] = { + ...(baseValue as Record), + ...(accountValue as Record), + }; + } + } + return merged; } export function resolveMergedAccountConfig>(params: { @@ -142,6 +161,7 @@ export function resolveMergedAccountConfig string; + nestedObjectKeys?: string[]; }): TConfig { const accountConfig = params.normalizeAccountId ? resolveNormalizedAccountEntry(params.accounts, params.accountId, params.normalizeAccountId) @@ -150,5 +170,6 @@ export function resolveMergedAccountConfig