From 2f03aa1fad189d1dde0e3698c23e02d847a455f9 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:39:13 +0800 Subject: [PATCH] fix(channels): restore dingtalk multi-account support (#874) --- electron/utils/channel-config.ts | 136 ++++++++++++++++-------------- electron/utils/openclaw-auth.ts | 65 +++++++------- src/i18n/locales/en/channels.json | 15 +--- src/i18n/locales/ja/channels.json | 15 +--- src/i18n/locales/ru/channels.json | 15 +--- src/i18n/locales/zh/channels.json | 15 +--- src/types/channel.ts | 22 ----- tests/unit/openclaw-auth.test.ts | 14 ++- 8 files changed, 114 insertions(+), 183 deletions(-) diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index c8d62d04c..02001e03b 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -26,11 +26,12 @@ const WECOM_PLUGIN_ID = 'wecom'; const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE; const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const; const DEFAULT_ACCOUNT_ID = 'default'; -// Channels whose plugin schema uses additionalProperties:false, meaning -// credential keys MUST NOT appear at the top level of `channels.`. -// All other channels get the default account mirrored to the top level -// so their runtime/plugin can discover the credentials. -const CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR = new Set(['dingtalk']); +// Channels whose top-level schema (additionalProperties:false) does NOT +// include `defaultAccount`. We still use the multi-account `accounts` +// map, but strip `defaultAccount` before persisting to avoid plugin +// schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID +// when `defaultAccount` is absent. +const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']); const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']); const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID); const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json'); @@ -77,6 +78,22 @@ const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record = { // ── Helpers ────────────────────────────────────────────────────── +/** + * Strip `defaultAccount` from channel sections whose plugin schema + * declares additionalProperties:false without listing `defaultAccount`. + * Call right before every `writeOpenClawConfig` in channel-config + * mutation functions. + */ +function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void { + if (!config.channels) return; + for (const channelType of CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY) { + const section = config.channels[channelType]; + if (section) { + delete section.defaultAccount; + } + } +} + async function fileExists(p: string): Promise { try { await access(p, constants.F_OK); return true; } catch { return false; } } @@ -606,6 +623,18 @@ function transformChannelConfig( transformedConfig.allowFrom = allowFrom; } + if (channelType === 'dingtalk') { + // The per-account schema uses additionalProperties:false and does + // NOT include these legacy/obsolete fields. Strip them before + // writing to accounts. to avoid schema validation errors. + // robotCode – never existed in the plugin schema; clientId IS the robot code + // corpId – top-level only, legacy compat, runtime ignores it + // agentId – top-level only, legacy compat, runtime ignores it + delete transformedConfig.robotCode; + delete transformedConfig.corpId; + delete transformedConfig.agentId; + } + return transformedConfig; } @@ -767,52 +796,38 @@ export async function saveChannelConfig( } } - // ── Strict-schema channels (e.g. dingtalk) ────────────────────── - // These plugins declare additionalProperties:false and do NOT - // recognise `accounts` / `defaultAccount`. Write credentials - // flat to the channel root and strip the multi-account keys. - if (CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(resolvedChannelType)) { - for (const [key, value] of Object.entries(transformedConfig)) { + // ── Write into accounts. (multi-account support) ─── + const accounts = ensureChannelAccountsMap(channelSection); + channelSection.defaultAccount = + typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount + : resolvedAccountId; + accounts[resolvedAccountId] = { + ...accounts[resolvedAccountId], + ...transformedConfig, + enabled: transformedConfig.enabled ?? true, + }; + + // Keep channel-level enabled explicit so callers/tests that + // read channels..enabled still work. + channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true; + + // Most OpenClaw channel plugins/built-ins also read the default + // account's credentials from the top level of `channels.` + // (e.g. channels.feishu.appId). Mirror them there so the + // runtime can discover them. + const mirroredAccountId = + typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount + : resolvedAccountId; + const defaultAccountData = accounts[mirroredAccountId] ?? accounts[resolvedAccountId] ?? accounts[DEFAULT_ACCOUNT_ID]; + if (defaultAccountData) { + for (const [key, value] of Object.entries(defaultAccountData)) { channelSection[key] = value; } - channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true; - // Remove keys the strict schema rejects - delete channelSection.accounts; - delete channelSection.defaultAccount; - } else { - // ── Normal channels ────────────────────────────────────────── - // Write into accounts. (multi-account support). - const accounts = ensureChannelAccountsMap(channelSection); - channelSection.defaultAccount = - typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() - ? channelSection.defaultAccount - : resolvedAccountId; - accounts[resolvedAccountId] = { - ...accounts[resolvedAccountId], - ...transformedConfig, - enabled: transformedConfig.enabled ?? true, - }; - - // Keep channel-level enabled explicit so callers/tests that - // read channels..enabled still work. - channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true; - - // Most OpenClaw channel plugins/built-ins also read the default - // account's credentials from the top level of `channels.` - // (e.g. channels.feishu.appId). Mirror them there so the - // runtime can discover them. - const mirroredAccountId = - typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() - ? channelSection.defaultAccount - : resolvedAccountId; - const defaultAccountData = accounts[mirroredAccountId] ?? accounts[resolvedAccountId] ?? accounts[DEFAULT_ACCOUNT_ID]; - if (defaultAccountData) { - for (const [key, value] of Object.entries(defaultAccountData)) { - channelSection[key] = value; - } - } } + sanitizeChannelSectionsBeforeWrite(currentConfig); await writeOpenClawConfig(currentConfig); logger.info('Channel config saved', { channelType: resolvedChannelType, @@ -909,15 +924,6 @@ export async function deleteChannelAccountConfig(channelType: string, accountId: return; } - // Strict-schema channels have no `accounts` structure — delete means - // removing the entire channel section. - if (CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(resolvedChannelType)) { - delete currentConfig.channels![resolvedChannelType]; - syncBuiltinChannelsWithPluginAllowlist(currentConfig); - await writeOpenClawConfig(currentConfig); - logger.info('Deleted strict-schema channel config', { channelType: resolvedChannelType, accountId }); - return; - } migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); const accounts = getChannelAccountsMap(channelSection); @@ -967,6 +973,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId: } syncBuiltinChannelsWithPluginAllowlist(currentConfig); + sanitizeChannelSectionsBeforeWrite(currentConfig); await writeOpenClawConfig(currentConfig); if (isWechatChannelType(resolvedChannelType)) { await deleteWeChatAccountState(accountId); @@ -1159,6 +1166,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s channelSection[key] = value; } + sanitizeChannelSectionsBeforeWrite(currentConfig); await writeOpenClawConfig(currentConfig); logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId }); }); @@ -1177,15 +1185,12 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); const accounts = getChannelAccountsMap(section); if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) { - // Strict-schema channels have no accounts map; skip them. - // For normal channels, ensure top-level mirror is consistent. - if (!CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(channelType)) { - const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID; - const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID]; - if (defaultAccountData) { - for (const [key, value] of Object.entries(defaultAccountData)) { - section[key] = value; - } + // Ensure top-level mirror is consistent. + const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID; + const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID]; + if (defaultAccountData) { + for (const [key, value] of Object.entries(defaultAccountData)) { + section[key] = value; } } continue; @@ -1222,6 +1227,7 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc } if (modified) { + sanitizeChannelSectionsBeforeWrite(currentConfig); await writeOpenClawConfig(currentConfig); logger.info('Deleted all channel accounts for agent', { agentId, accountId }); } diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 9242a12a1..5f135e0f5 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1946,50 +1946,43 @@ export async function sanitizeOpenClawConfig(): Promise { // credentials from the top level of `channels.`. Mirror them // there so the runtime can discover them. // - // Strict-schema channels (e.g. dingtalk, additionalProperties:false) - // reject the `accounts` / `defaultAccount` keys entirely — strip them - // so the Gateway doesn't crash on startup. + // Channels whose top-level schema (additionalProperties:false) does NOT + // include `defaultAccount` but DOES include `accounts`. Strip only + // `defaultAccount` to allow multi-account support. const channelsObj = config.channels as Record> | undefined; - const CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR = new Set(['dingtalk']); + const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']); if (channelsObj && typeof channelsObj === 'object') { for (const [channelType, section] of Object.entries(channelsObj)) { if (!section || typeof section !== 'object') continue; - if (CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(channelType)) { - // Strict-schema channel: strip `accounts` and `defaultAccount`. - // Credentials should live flat at the channel root. - if ('accounts' in section) { - delete section['accounts']; - modified = true; - console.log(`[sanitize] Removed incompatible 'accounts' from channels.${channelType}`); - } - if ('defaultAccount' in section) { - delete section['defaultAccount']; - modified = true; - console.log(`[sanitize] Removed incompatible 'defaultAccount' from channels.${channelType}`); - } - } else { - // Normal channel: mirror missing keys from default account to top level. - const accounts = section.accounts as Record> | undefined; - const defaultAccountId = - typeof section.defaultAccount === 'string' && section.defaultAccount.trim() - ? section.defaultAccount - : 'default'; - const defaultAccountData = accounts?.[defaultAccountId] ?? accounts?.['default']; - if (!defaultAccountData || typeof defaultAccountData !== 'object') continue; - let mirrored = false; - for (const [key, value] of Object.entries(defaultAccountData)) { - if (!(key in section)) { - section[key] = value; - mirrored = true; - } - } - if (mirrored) { - modified = true; - console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`); + // Channels that accept accounts but not defaultAccount: + // strip defaultAccount only. + if (CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY.has(channelType) && 'defaultAccount' in section) { + delete section['defaultAccount']; + modified = true; + console.log(`[sanitize] Removed incompatible 'defaultAccount' from channels.${channelType}`); + } + + // Mirror missing keys from default account to top level. + const accounts = section.accounts as Record> | undefined; + const defaultAccountId = + typeof section.defaultAccount === 'string' && section.defaultAccount.trim() + ? section.defaultAccount + : 'default'; + const defaultAccountData = accounts?.[defaultAccountId] ?? accounts?.['default']; + if (!defaultAccountData || typeof defaultAccountData !== 'object') continue; + let mirrored = false; + for (const [key, value] of Object.entries(defaultAccountData)) { + if (!(key in section)) { + section[key] = value; + mirrored = true; } } + if (mirrored) { + modified = true; + console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`); + } } } diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index 0f812d8fa..6f603e63c 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -206,25 +206,12 @@ "clientSecret": { "label": "Client Secret (AppSecret)", "placeholder": "Your app secret" - }, - "robotCode": { - "label": "Robot Code (optional)", - "placeholder": "Usually same as Client ID" - }, - "corpId": { - "label": "Corp ID (optional)", - "placeholder": "dingxxxxxx" - }, - "agentId": { - "label": "Agent ID (optional)", - "placeholder": "123456789" } }, "instructions": [ "Install and enable the dingtalk plugin in OpenClaw", "Create a DingTalk internal app and enable Stream mode", - "Fill in Client ID and Client Secret (required)", - "Fill in Robot Code / Corp ID / Agent ID if your setup requires them" + "Fill in Client ID (AppKey) and Client Secret (AppSecret)" ] }, "signal": { diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index 94af46e19..3cb80ca5a 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -206,25 +206,12 @@ "clientSecret": { "label": "Client Secret (AppSecret)", "placeholder": "アプリのシークレット" - }, - "robotCode": { - "label": "Robot Code(任意)", - "placeholder": "通常は Client ID と同じ" - }, - "corpId": { - "label": "Corp ID(任意)", - "placeholder": "dingxxxxxx" - }, - "agentId": { - "label": "Agent ID(任意)", - "placeholder": "123456789" } }, "instructions": [ "まず OpenClaw に dingtalk プラグインをインストールして有効化します", "DingTalk 開発者コンソールで社内アプリを作成し Stream モードを有効にします", - "Client ID と Client Secret を入力します(必須)", - "必要に応じて Robot Code / Corp ID / Agent ID を入力します" + "Client ID (AppKey) と Client Secret (AppSecret) を入力します" ] }, "signal": { diff --git a/src/i18n/locales/ru/channels.json b/src/i18n/locales/ru/channels.json index d31c4b46a..a8bd7e9b4 100644 --- a/src/i18n/locales/ru/channels.json +++ b/src/i18n/locales/ru/channels.json @@ -181,25 +181,12 @@ "clientSecret": { "label": "Client Secret (AppSecret)", "placeholder": "Секрет вашего приложения" - }, - "robotCode": { - "label": "Robot Code (опционально)", - "placeholder": "Обычно совпадает с Client ID" - }, - "corpId": { - "label": "Corp ID (опционально)", - "placeholder": "dingxxxxxx" - }, - "agentId": { - "label": "Agent ID (опционально)", - "placeholder": "123456789" } }, "instructions": [ "Установите и включите плагин dingtalk в OpenClaw", "Создайте внутреннее приложение DingTalk и включите режим Stream", - "Заполните Client ID и Client Secret (обязательно)", - "Заполните Robot Code / Corp ID / Agent ID, если ваша настройка требует этого" + "Заполните Client ID (AppKey) и Client Secret (AppSecret)" ] }, "signal": { diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 1ed42d73c..744b3338e 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -206,25 +206,12 @@ "clientSecret": { "label": "Client Secret (AppSecret)", "placeholder": "您的应用密钥" - }, - "robotCode": { - "label": "Robot Code(可选)", - "placeholder": "通常与 Client ID 相同" - }, - "corpId": { - "label": "Corp ID(可选)", - "placeholder": "dingxxxxxx" - }, - "agentId": { - "label": "Agent ID(可选)", - "placeholder": "123456789" } }, "instructions": [ "先在 OpenClaw 安装并启用 dingtalk 插件", "在钉钉开发者后台创建企业内部应用并开启 Stream 模式", - "填写 Client ID 和 Client Secret(必填)", - "根据你的应用配置按需填写 Robot Code / Corp ID / Agent ID" + "填写 Client ID (AppKey) 和 Client Secret (AppSecret)" ] }, "signal": { diff --git a/src/types/channel.ts b/src/types/channel.ts index 0fa515494..aff732259 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -174,33 +174,11 @@ export const CHANNEL_META: Record = { placeholder: 'channels:meta.dingtalk.fields.clientSecret.placeholder', required: true, }, - { - key: 'robotCode', - label: 'channels:meta.dingtalk.fields.robotCode.label', - type: 'text', - placeholder: 'channels:meta.dingtalk.fields.robotCode.placeholder', - required: false, - }, - { - key: 'corpId', - label: 'channels:meta.dingtalk.fields.corpId.label', - type: 'text', - placeholder: 'channels:meta.dingtalk.fields.corpId.placeholder', - required: false, - }, - { - key: 'agentId', - label: 'channels:meta.dingtalk.fields.agentId.label', - type: 'text', - placeholder: 'channels:meta.dingtalk.fields.agentId.placeholder', - required: false, - }, ], instructions: [ 'channels:meta.dingtalk.instructions.0', 'channels:meta.dingtalk.instructions.1', 'channels:meta.dingtalk.instructions.2', - 'channels:meta.dingtalk.instructions.3', ], isPlugin: true, }, diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index c7eba23b4..653e9fa19 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -455,7 +455,7 @@ describe('sanitizeOpenClawConfig', () => { expect(telegram.botToken).toBe('telegram-token'); }); - it('strips accounts/defaultAccount from dingtalk (strict-schema channel) during sanitize', async () => { + it('strips defaultAccount (but preserves accounts) from dingtalk during sanitize', async () => { await writeOpenClawJson({ channels: { dingtalk: { @@ -480,11 +480,17 @@ describe('sanitizeOpenClawConfig', () => { const result = await readOpenClawJson(); const channels = result.channels as Record>; const dingtalk = channels.dingtalk; - // dingtalk's strict schema rejects accounts/defaultAccount — they must be stripped + // dingtalk's schema accepts `accounts` but NOT `defaultAccount` expect(dingtalk.enabled).toBe(true); - expect(dingtalk.accounts).toBeUndefined(); + expect(dingtalk.accounts).toEqual({ + default: { + clientId: 'dt-client-id-nested', + clientSecret: 'dt-secret-nested', + enabled: true, + }, + }); expect(dingtalk.defaultAccount).toBeUndefined(); - // Top-level credentials must be preserved + // Top-level credentials preserved (were already there + mirrored) expect(dingtalk.clientId).toBe('dt-client-id'); expect(dingtalk.clientSecret).toBe('dt-secret'); });