diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts index a70f8afbd74..70bf55ab0a6 100644 --- a/extensions/bluebubbles/src/accounts.ts +++ b/extensions/bluebubbles/src/accounts.ts @@ -1,5 +1,8 @@ -import { createAccountListHelpers, mergeAccountConfig } from "openclaw/plugin-sdk/account-helpers"; -import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + createAccountListHelpers, + normalizeAccountId, + resolveMergedAccountConfig, +} from "openclaw/plugin-sdk/account-resolution"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js"; import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js"; @@ -19,29 +22,19 @@ const { } = createAccountListHelpers("bluebubbles"); export { listBlueBubblesAccountIds, resolveDefaultBlueBubblesAccountId }; -function resolveAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): BlueBubblesAccountConfig | undefined { - const accounts = cfg.channels?.bluebubbles?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - return accounts[accountId] as BlueBubblesAccountConfig | undefined; -} - function mergeBlueBubblesAccountConfig( cfg: OpenClawConfig, accountId: string, ): BlueBubblesAccountConfig { - const account = resolveAccountConfig(cfg, accountId) ?? {}; - const merged = mergeAccountConfig({ + const merged = resolveMergedAccountConfig({ channelConfig: cfg.channels?.bluebubbles as BlueBubblesAccountConfig | undefined, - accountConfig: account, + accounts: cfg.channels?.bluebubbles?.accounts as + | Record> + | undefined, + accountId, omitKeys: ["defaultAccount"], }); - const chunkMode = account.chunkMode ?? merged.chunkMode ?? "length"; - return { ...merged, chunkMode }; + return { ...merged, chunkMode: merged.chunkMode ?? "length" }; } export function resolveBlueBubblesAccount(params: { diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index cb3f35a329e..178e647637a 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -1,7 +1,9 @@ import { DEFAULT_ACCOUNT_ID, - mergeAccountConfig, + createAccountListHelpers, normalizeAccountId, + normalizeOptionalAccountId, + resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-resolution"; import type { ClawdbotConfig } from "../runtime-api.js"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js"; @@ -13,29 +15,15 @@ import type { ResolvedFeishuAccount, } from "./types.js"; -/** - * List all configured account IDs from the accounts field. - */ -function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { - const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts; - if (!accounts || typeof accounts !== "object") { - return []; - } - return Object.keys(accounts).filter(Boolean); -} +const { + listConfiguredAccountIds, + listAccountIds: listFeishuAccountIds, + resolveDefaultAccountId, +} = createAccountListHelpers("feishu", { + allowUnlistedDefaultAccount: true, +}); -/** - * List all Feishu account IDs. - * If no accounts are configured, returns [DEFAULT_ACCOUNT_ID] for backward compatibility. - */ -export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] { - const ids = listConfiguredAccountIds(cfg); - if (ids.length === 0) { - // Backward compatibility: no accounts configured, use default - return [DEFAULT_ACCOUNT_ID]; - } - return [...ids].toSorted((a, b) => a.localeCompare(b)); -} +export { listFeishuAccountIds }; /** * Resolve the default account selection and its source. @@ -44,8 +32,9 @@ export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): { accountId: string; source: FeishuDefaultAccountSelectionSource; } { - const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim(); - const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined; + const preferred = normalizeOptionalAccountId( + (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount, + ); if (preferred) { return { accountId: preferred, @@ -69,21 +58,7 @@ export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): { * Resolve the default account ID. */ export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string { - return resolveDefaultFeishuAccountSelection(cfg).accountId; -} - -/** - * Get the raw account-specific config. - */ -function resolveAccountConfig( - cfg: ClawdbotConfig, - accountId: string, -): FeishuAccountConfig | undefined { - const accounts = (cfg.channels?.feishu as FeishuConfig)?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - return accounts[accountId]; + return resolveDefaultAccountId(cfg); } /** @@ -92,9 +67,10 @@ function resolveAccountConfig( */ function mergeFeishuAccountConfig(cfg: ClawdbotConfig, accountId: string): FeishuConfig { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - return mergeAccountConfig({ + return resolveMergedAccountConfig({ channelConfig: feishuCfg, - accountConfig: resolveAccountConfig(cfg, accountId), + accounts: feishuCfg?.accounts as Record> | undefined, + accountId, omitKeys: ["defaultAccount"], }); } diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 09b6263bab9..7d878f3ecfb 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -1,5 +1,10 @@ -import { createAccountListHelpers, mergeAccountConfig } from "openclaw/plugin-sdk/account-helpers"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + createAccountListHelpers, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveAccountEntry, + resolveMergedAccountConfig, +} from "openclaw/plugin-sdk/account-resolution"; import { isSecretRef, type OpenClawConfig } from "openclaw/plugin-sdk/core"; import type { GoogleChatAccountConfig } from "./types.config.js"; @@ -24,31 +29,24 @@ const { } = createAccountListHelpers("googlechat"); export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId }; -function resolveAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): GoogleChatAccountConfig | undefined { - const accounts = cfg.channels?.["googlechat"]?.accounts; - if (!accounts || typeof accounts !== "object") { - return undefined; - } - return accounts[accountId]; -} - function mergeGoogleChatAccountConfig( cfg: OpenClawConfig, accountId: string, ): GoogleChatAccountConfig { const raw = cfg.channels?.["googlechat"] ?? {}; - const base = mergeAccountConfig({ + const base = resolveMergedAccountConfig({ channelConfig: raw as GoogleChatAccountConfig, - accountConfig: undefined, + accounts: raw.accounts as Record> | undefined, + accountId, omitKeys: ["defaultAccount"], }); - const defaultAccountConfig = resolveAccountConfig(cfg, DEFAULT_ACCOUNT_ID) ?? {}; - const account = resolveAccountConfig(cfg, accountId) ?? {}; + const defaultAccountConfig = + resolveAccountEntry( + raw.accounts as Record | undefined, + DEFAULT_ACCOUNT_ID, + ) ?? {}; if (accountId === DEFAULT_ACCOUNT_ID) { - return { ...base, ...defaultAccountConfig } as GoogleChatAccountConfig; + return base; } const { enabled: _ignoredEnabled, @@ -60,7 +58,7 @@ function mergeGoogleChatAccountConfig( } = defaultAccountConfig; // In multi-account setups, allow accounts.default to provide shared defaults // (for example webhook/audience fields) while preserving top-level and account overrides. - return { ...defaultAccountShared, ...base, ...account } as GoogleChatAccountConfig; + return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig; } function parseServiceAccount(value: unknown): Record | null { diff --git a/extensions/whatsapp/src/accounts.test.ts b/extensions/whatsapp/src/accounts.test.ts index c21c253bd9b..c9d88c3ce2b 100644 --- a/extensions/whatsapp/src/accounts.test.ts +++ b/extensions/whatsapp/src/accounts.test.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveWhatsAppAuthDir } from "./accounts.js"; +import { resolveWhatsAppAccount, resolveWhatsAppAuthDir } from "./accounts.js"; describe("resolveWhatsAppAuthDir", () => { const stubCfg = { channels: { whatsapp: { accounts: {} } } } as Parameters< @@ -44,4 +44,31 @@ describe("resolveWhatsAppAuthDir", () => { }); expect(authDir).toMatch(/whatsapp[/\\]my-account-1$/); }); + + it("merges top-level and account-specific config through shared helpers", () => { + const resolved = resolveWhatsAppAccount({ + cfg: { + messages: { + messagePrefix: "[global]", + }, + channels: { + whatsapp: { + sendReadReceipts: false, + messagePrefix: "[root]", + debounceMs: 100, + accounts: { + work: { + debounceMs: 250, + }, + }, + }, + }, + } as Parameters[0]["cfg"], + accountId: "work", + }); + + expect(resolved.sendReadReceipts).toBe(false); + expect(resolved.messagePrefix).toBe("[root]"); + expect(resolved.debounceMs).toBe(250); + }); }); diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 76fd919eeb2..bf9b9243099 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -5,6 +5,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountEntry, + resolveMergedAccountConfig, resolveUserPath, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; @@ -123,34 +124,38 @@ export function resolveWhatsAppAccount(params: { }): ResolvedWhatsAppAccount { const rootCfg = params.cfg.channels?.whatsapp; const accountId = params.accountId?.trim() || resolveDefaultWhatsAppAccountId(params.cfg); - const accountCfg = resolveAccountConfig(params.cfg, accountId); - const enabled = accountCfg?.enabled !== false; + const merged = resolveMergedAccountConfig({ + channelConfig: rootCfg as WhatsAppAccountConfig | undefined, + accounts: rootCfg?.accounts as Record> | undefined, + accountId, + omitKeys: ["defaultAccount"], + }); + const enabled = merged.enabled !== false; const { authDir, isLegacy } = resolveWhatsAppAuthDir({ cfg: params.cfg, accountId, }); return { accountId, - name: accountCfg?.name?.trim() || undefined, + name: merged.name?.trim() || undefined, enabled, - sendReadReceipts: accountCfg?.sendReadReceipts ?? rootCfg?.sendReadReceipts ?? true, - messagePrefix: - accountCfg?.messagePrefix ?? rootCfg?.messagePrefix ?? params.cfg.messages?.messagePrefix, - defaultTo: accountCfg?.defaultTo ?? rootCfg?.defaultTo, + sendReadReceipts: merged.sendReadReceipts ?? true, + messagePrefix: merged.messagePrefix ?? params.cfg.messages?.messagePrefix, + defaultTo: merged.defaultTo, authDir, isLegacyAuthDir: isLegacy, - selfChatMode: accountCfg?.selfChatMode ?? rootCfg?.selfChatMode, - dmPolicy: accountCfg?.dmPolicy ?? rootCfg?.dmPolicy, - allowFrom: accountCfg?.allowFrom ?? rootCfg?.allowFrom, - groupAllowFrom: accountCfg?.groupAllowFrom ?? rootCfg?.groupAllowFrom, - groupPolicy: accountCfg?.groupPolicy ?? rootCfg?.groupPolicy, - textChunkLimit: accountCfg?.textChunkLimit ?? rootCfg?.textChunkLimit, - chunkMode: accountCfg?.chunkMode ?? rootCfg?.chunkMode, - mediaMaxMb: accountCfg?.mediaMaxMb ?? rootCfg?.mediaMaxMb, - blockStreaming: accountCfg?.blockStreaming ?? rootCfg?.blockStreaming, - ackReaction: accountCfg?.ackReaction ?? rootCfg?.ackReaction, - groups: accountCfg?.groups ?? rootCfg?.groups, - debounceMs: accountCfg?.debounceMs ?? rootCfg?.debounceMs, + selfChatMode: merged.selfChatMode, + dmPolicy: merged.dmPolicy, + allowFrom: merged.allowFrom, + groupAllowFrom: merged.groupAllowFrom, + groupPolicy: merged.groupPolicy, + textChunkLimit: merged.textChunkLimit, + chunkMode: merged.chunkMode, + mediaMaxMb: merged.mediaMaxMb, + blockStreaming: merged.blockStreaming, + ackReaction: merged.ackReaction, + groups: merged.groups, + debounceMs: merged.debounceMs, }; } diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 532def59ce7..4ab41c19067 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -111,6 +111,16 @@ describe("createAccountListHelpers", () => { it('returns "default" for empty config', () => { expect(resolveDefaultAccountId({} as OpenClawConfig)).toBe("default"); }); + + it("can preserve configured defaults that are not present in accounts", () => { + const preserveDefault = createAccountListHelpers("testchannel", { + allowUnlistedDefaultAccount: true, + }); + + expect(preserveDefault.resolveDefaultAccountId(cfg({ default: {}, zeta: {} }, "ops"))).toBe( + "ops", + ); + }); }); }); diff --git a/src/channels/plugins/account-helpers.ts b/src/channels/plugins/account-helpers.ts index 7fc7694475c..cb5a7156bac 100644 --- a/src/channels/plugins/account-helpers.ts +++ b/src/channels/plugins/account-helpers.ts @@ -11,7 +11,10 @@ import { export function createAccountListHelpers( channelKey: string, - options?: { normalizeAccountId?: (id: string) => string }, + options?: { + normalizeAccountId?: (id: string) => string; + allowUnlistedDefaultAccount?: boolean; + }, ) { function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined { const channel = cfg.channels?.[channelKey] as Record | undefined; @@ -22,6 +25,9 @@ export function createAccountListHelpers( return undefined; } const ids = listAccountIds(cfg); + if (options?.allowUnlistedDefaultAccount) { + return preferred; + } if (ids.some((id) => normalizeAccountId(id) === preferred)) { return preferred; }