fix(channels): restore dingtalk multi-account support (#874)
This commit is contained in:
committed by
GitHub
Unverified
parent
3a424ef692
commit
2f03aa1fad
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user