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 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.<type>`.
// 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<string, string> = {
// ── 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> {
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.<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;
}
@@ -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.<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;
}
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);
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 });
}

View File

@@ -1946,50 +1946,43 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
// credentials from the top level of `channels.<type>`. 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<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') {
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<string, Record<string, unknown>> | 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<string, Record<string, unknown>> | 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}`);
}
}
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -174,33 +174,11 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
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,
},

View File

@@ -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<string, Record<string, unknown>>;
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');
});