fix(channels): restore dingtalk multi-account support (#874)

This commit is contained in:
paisley
2026-04-19 17:39:13 +08:00
committed by GitHub
Unverified
parent 3a424ef692
commit 2f03aa1fad
8 changed files with 114 additions and 183 deletions

View File

@@ -26,11 +26,12 @@ const WECOM_PLUGIN_ID = 'wecom';
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE; const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const; const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const DEFAULT_ACCOUNT_ID = 'default'; const DEFAULT_ACCOUNT_ID = 'default';
// Channels whose plugin schema uses additionalProperties:false, meaning // Channels whose top-level schema (additionalProperties:false) does NOT
// credential keys MUST NOT appear at the top level of `channels.<type>`. // include `defaultAccount`. We still use the multi-account `accounts`
// All other channels get the default account mirrored to the top level // map, but strip `defaultAccount` before persisting to avoid plugin
// so their runtime/plugin can discover the credentials. // schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID
const CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR = new Set(['dingtalk']); // 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 CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID); const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json'); const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
@@ -77,6 +78,22 @@ const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
// ── Helpers ────────────────────────────────────────────────────── // ── 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<boolean> { async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; } try { await access(p, constants.F_OK); return true; } catch { return false; }
} }
@@ -606,6 +623,18 @@ function transformChannelConfig(
transformedConfig.allowFrom = allowFrom; 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.<id> 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; return transformedConfig;
} }
@@ -767,52 +796,38 @@ export async function saveChannelConfig(
} }
} }
// ── Strict-schema channels (e.g. dingtalk) ────────────────────── // ── Write into accounts.<accountId> (multi-account support) ───
// These plugins declare additionalProperties:false and do NOT const accounts = ensureChannelAccountsMap(channelSection);
// recognise `accounts` / `defaultAccount`. Write credentials channelSection.defaultAccount =
// flat to the channel root and strip the multi-account keys. typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
if (CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(resolvedChannelType)) { ? channelSection.defaultAccount
for (const [key, value] of Object.entries(transformedConfig)) { : resolvedAccountId;
accounts[resolvedAccountId] = {
...accounts[resolvedAccountId],
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
// Keep channel-level enabled explicit so callers/tests that
// read channels.<type>.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.<type>`
// (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[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.<accountId> (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.<type>.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.<type>`
// (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); await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', { logger.info('Channel config saved', {
channelType: resolvedChannelType, channelType: resolvedChannelType,
@@ -909,15 +924,6 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
return; 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); migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = getChannelAccountsMap(channelSection); const accounts = getChannelAccountsMap(channelSection);
@@ -967,6 +973,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
} }
syncBuiltinChannelsWithPluginAllowlist(currentConfig); syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
if (isWechatChannelType(resolvedChannelType)) { if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatAccountState(accountId); await deleteWeChatAccountState(accountId);
@@ -1159,6 +1166,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
channelSection[key] = value; channelSection[key] = value;
} }
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId }); 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); migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = getChannelAccountsMap(section); const accounts = getChannelAccountsMap(section);
if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) { if (!accounts?.[accountId] || (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`))) {
// Strict-schema channels have no accounts map; skip them. // Ensure top-level mirror is consistent.
// For normal channels, ensure top-level mirror is consistent. const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID;
if (!CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(channelType)) { const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID];
const mirroredAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount : DEFAULT_ACCOUNT_ID; if (defaultAccountData) {
const defaultAccountData = accounts?.[mirroredAccountId] ?? accounts?.[DEFAULT_ACCOUNT_ID]; for (const [key, value] of Object.entries(defaultAccountData)) {
if (defaultAccountData) { section[key] = value;
for (const [key, value] of Object.entries(defaultAccountData)) {
section[key] = value;
}
} }
} }
continue; continue;
@@ -1222,6 +1227,7 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
} }
if (modified) { if (modified) {
sanitizeChannelSectionsBeforeWrite(currentConfig);
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
logger.info('Deleted all channel accounts for agent', { agentId, accountId }); logger.info('Deleted all channel accounts for agent', { agentId, accountId });
} }

View File

@@ -1946,50 +1946,43 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
// credentials from the top level of `channels.<type>`. Mirror them // credentials from the top level of `channels.<type>`. Mirror them
// there so the runtime can discover them. // there so the runtime can discover them.
// //
// Strict-schema channels (e.g. dingtalk, additionalProperties:false) // Channels whose top-level schema (additionalProperties:false) does NOT
// reject the `accounts` / `defaultAccount` keys entirely — strip them // include `defaultAccount` but DOES include `accounts`. Strip only
// so the Gateway doesn't crash on startup. // `defaultAccount` to allow multi-account support.
const channelsObj = config.channels as Record<string, Record<string, unknown>> | undefined; const channelsObj = config.channels as Record<string, Record<string, unknown>> | undefined;
const CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR = new Set(['dingtalk']); const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']);
if (channelsObj && typeof channelsObj === 'object') { if (channelsObj && typeof channelsObj === 'object') {
for (const [channelType, section] of Object.entries(channelsObj)) { for (const [channelType, section] of Object.entries(channelsObj)) {
if (!section || typeof section !== 'object') continue; if (!section || typeof section !== 'object') continue;
if (CHANNELS_EXCLUDING_TOP_LEVEL_MIRROR.has(channelType)) { // Channels that accept accounts but not defaultAccount:
// Strict-schema channel: strip `accounts` and `defaultAccount`. // strip defaultAccount only.
// Credentials should live flat at the channel root. if (CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY.has(channelType) && 'defaultAccount' in section) {
if ('accounts' in section) { delete section['defaultAccount'];
delete section['accounts']; modified = true;
modified = true; console.log(`[sanitize] Removed incompatible 'defaultAccount' from channels.${channelType}`);
console.log(`[sanitize] Removed incompatible 'accounts' from channels.${channelType}`); }
}
if ('defaultAccount' in section) { // Mirror missing keys from default account to top level.
delete section['defaultAccount']; const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
modified = true; const defaultAccountId =
console.log(`[sanitize] Removed incompatible 'defaultAccount' from channels.${channelType}`); typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
} ? section.defaultAccount
} else { : 'default';
// Normal channel: mirror missing keys from default account to top level. const defaultAccountData = accounts?.[defaultAccountId] ?? accounts?.['default'];
const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined; if (!defaultAccountData || typeof defaultAccountData !== 'object') continue;
const defaultAccountId = let mirrored = false;
typeof section.defaultAccount === 'string' && section.defaultAccount.trim() for (const [key, value] of Object.entries(defaultAccountData)) {
? section.defaultAccount if (!(key in section)) {
: 'default'; section[key] = value;
const defaultAccountData = accounts?.[defaultAccountId] ?? accounts?.['default']; mirrored = true;
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}`);
} }
} }
if (mirrored) {
modified = true;
console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`);
}
} }
} }

View File

@@ -206,25 +206,12 @@
"clientSecret": { "clientSecret": {
"label": "Client Secret (AppSecret)", "label": "Client Secret (AppSecret)",
"placeholder": "Your app secret" "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": [ "instructions": [
"Install and enable the dingtalk plugin in OpenClaw", "Install and enable the dingtalk plugin in OpenClaw",
"Create a DingTalk internal app and enable Stream mode", "Create a DingTalk internal app and enable Stream mode",
"Fill in Client ID and Client Secret (required)", "Fill in Client ID (AppKey) and Client Secret (AppSecret)"
"Fill in Robot Code / Corp ID / Agent ID if your setup requires them"
] ]
}, },
"signal": { "signal": {

View File

@@ -206,25 +206,12 @@
"clientSecret": { "clientSecret": {
"label": "Client Secret (AppSecret)", "label": "Client Secret (AppSecret)",
"placeholder": "アプリのシークレット" "placeholder": "アプリのシークレット"
},
"robotCode": {
"label": "Robot Code任意",
"placeholder": "通常は Client ID と同じ"
},
"corpId": {
"label": "Corp ID任意",
"placeholder": "dingxxxxxx"
},
"agentId": {
"label": "Agent ID任意",
"placeholder": "123456789"
} }
}, },
"instructions": [ "instructions": [
"まず OpenClaw に dingtalk プラグインをインストールして有効化します", "まず OpenClaw に dingtalk プラグインをインストールして有効化します",
"DingTalk 開発者コンソールで社内アプリを作成し Stream モードを有効にします", "DingTalk 開発者コンソールで社内アプリを作成し Stream モードを有効にします",
"Client ID と Client Secret を入力します(必須)", "Client ID (AppKey) と Client Secret (AppSecret) を入力します"
"必要に応じて Robot Code / Corp ID / Agent ID を入力します"
] ]
}, },
"signal": { "signal": {

View File

@@ -181,25 +181,12 @@
"clientSecret": { "clientSecret": {
"label": "Client Secret (AppSecret)", "label": "Client Secret (AppSecret)",
"placeholder": "Секрет вашего приложения" "placeholder": "Секрет вашего приложения"
},
"robotCode": {
"label": "Robot Code (опционально)",
"placeholder": "Обычно совпадает с Client ID"
},
"corpId": {
"label": "Corp ID (опционально)",
"placeholder": "dingxxxxxx"
},
"agentId": {
"label": "Agent ID (опционально)",
"placeholder": "123456789"
} }
}, },
"instructions": [ "instructions": [
"Установите и включите плагин dingtalk в OpenClaw", "Установите и включите плагин dingtalk в OpenClaw",
"Создайте внутреннее приложение DingTalk и включите режим Stream", "Создайте внутреннее приложение DingTalk и включите режим Stream",
"Заполните Client ID и Client Secret (обязательно)", "Заполните Client ID (AppKey) и Client Secret (AppSecret)"
"Заполните Robot Code / Corp ID / Agent ID, если ваша настройка требует этого"
] ]
}, },
"signal": { "signal": {

View File

@@ -206,25 +206,12 @@
"clientSecret": { "clientSecret": {
"label": "Client Secret (AppSecret)", "label": "Client Secret (AppSecret)",
"placeholder": "您的应用密钥" "placeholder": "您的应用密钥"
},
"robotCode": {
"label": "Robot Code可选",
"placeholder": "通常与 Client ID 相同"
},
"corpId": {
"label": "Corp ID可选",
"placeholder": "dingxxxxxx"
},
"agentId": {
"label": "Agent ID可选",
"placeholder": "123456789"
} }
}, },
"instructions": [ "instructions": [
"先在 OpenClaw 安装并启用 dingtalk 插件", "先在 OpenClaw 安装并启用 dingtalk 插件",
"在钉钉开发者后台创建企业内部应用并开启 Stream 模式", "在钉钉开发者后台创建企业内部应用并开启 Stream 模式",
"填写 Client ID 和 Client Secret(必填)", "填写 Client ID (AppKey) 和 Client Secret (AppSecret)"
"根据你的应用配置按需填写 Robot Code / Corp ID / Agent ID"
] ]
}, },
"signal": { "signal": {

View File

@@ -174,33 +174,11 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
placeholder: 'channels:meta.dingtalk.fields.clientSecret.placeholder', placeholder: 'channels:meta.dingtalk.fields.clientSecret.placeholder',
required: true, 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: [ instructions: [
'channels:meta.dingtalk.instructions.0', 'channels:meta.dingtalk.instructions.0',
'channels:meta.dingtalk.instructions.1', 'channels:meta.dingtalk.instructions.1',
'channels:meta.dingtalk.instructions.2', 'channels:meta.dingtalk.instructions.2',
'channels:meta.dingtalk.instructions.3',
], ],
isPlugin: true, isPlugin: true,
}, },

View File

@@ -455,7 +455,7 @@ describe('sanitizeOpenClawConfig', () => {
expect(telegram.botToken).toBe('telegram-token'); 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({ await writeOpenClawJson({
channels: { channels: {
dingtalk: { dingtalk: {
@@ -480,11 +480,17 @@ describe('sanitizeOpenClawConfig', () => {
const result = await readOpenClawJson(); const result = await readOpenClawJson();
const channels = result.channels as Record<string, Record<string, unknown>>; const channels = result.channels as Record<string, Record<string, unknown>>;
const dingtalk = channels.dingtalk; 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.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(); 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.clientId).toBe('dt-client-id');
expect(dingtalk.clientSecret).toBe('dt-secret'); expect(dingtalk.clientSecret).toBe('dt-secret');
}); });