diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 2061fac..f767b45 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3069,6 +3069,86 @@ function bindingIdentityMatches(binding, agentId, targetMatch) { ) } +function mergeMessagingRootEntry(cfg, storageKey, entry) { + if (!cfg.channels || typeof cfg.channels !== 'object' || Array.isArray(cfg.channels)) cfg.channels = {} + const existing = cfg.channels[storageKey] + cfg.channels[storageKey] = existing && typeof existing === 'object' && !Array.isArray(existing) + ? { ...existing, ...entry } + : entry +} + +function mergeMessagingAccountEntry(cfg, storageKey, accountId, entry) { + if (!cfg.channels || typeof cfg.channels !== 'object' || Array.isArray(cfg.channels)) cfg.channels = {} + const existingRoot = cfg.channels[storageKey] + const root = existingRoot && typeof existingRoot === 'object' && !Array.isArray(existingRoot) + ? existingRoot + : { enabled: true } + root.enabled = true + if (!root.accounts || typeof root.accounts !== 'object' || Array.isArray(root.accounts)) root.accounts = {} + const existingAccount = root.accounts[accountId] + root.accounts[accountId] = existingAccount && typeof existingAccount === 'object' && !Array.isArray(existingAccount) + ? { ...existingAccount, ...entry } + : entry + cfg.channels[storageKey] = root +} + +function applyMessagingPlatformEntry(cfg, storageKey, accountId, entry) { + const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' + if (normalizedAccountId) { + mergeMessagingAccountEntry(cfg, storageKey, normalizedAccountId, entry) + } else { + mergeMessagingRootEntry(cfg, storageKey, entry) + } +} + +function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) { + const entry = { enabled: true } + const storageKey = platformStorageKey(platform) + if (storageKey === 'telegram') { + entry.botToken = form.botToken + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + } else if (storageKey === 'discord') { + entry.token = form.token + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (form.guildId) { + const ck = form.channelId || '*' + entry.guilds = { [form.guildId]: { users: ['*'], requireMention: true, channels: { [ck]: { allow: true, requireMention: true } } } } + } + } else if (storageKey === 'feishu') { + entry.appId = form.appId + entry.appSecret = form.appSecret + entry.connectionMode = 'websocket' + entry.domain = form.domain + entry.webhookPath = form.webhookPath + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Object.hasOwn(form, 'requireMention')) entry.requireMention = !!form.requireMention + entry.reactionNotifications = form.reactionNotifications + entry.typingIndicator = form.typingIndicator + entry.resolveSenderNames = form.resolveSenderNames + } else { + Object.assign(entry, form) + } + preserveMessagingCredentialRefs(entry, form, currentSaved) + return entry +} + +export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, accountId } = {}) { + if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw new Error('openclaw.json 顶层必须是对象') + const storageKey = platformStorageKey(platform) + const normalizedForm = normalizeMessagingPlatformForm(platform, form || {}) + const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' + const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {} + const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved) + applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) + return { entry, accountId: normalizedAccountId, storageKey } +} + function triggerGatewayReloadNonBlocking(reason) { setTimeout(() => { try { @@ -4467,22 +4547,10 @@ const handlers = { const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {} const setRootChannelEntry = (entry) => { - const current = cfg.channels?.[storageKey] - // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段(streaming, retry, dmPolicy 等) - if (current && typeof current === 'object') { - cfg.channels[storageKey] = { ...current, ...entry } - } else { - cfg.channels[storageKey] = entry - } + mergeMessagingRootEntry(cfg, storageKey, entry) } const setAccountChannelEntry = (entry) => { - const current = cfg.channels?.[storageKey] && typeof cfg.channels[storageKey] === 'object' - ? cfg.channels[storageKey] - : { enabled: true } - current.enabled = true - if (!current.accounts || typeof current.accounts !== 'object') current.accounts = {} - current.accounts[normalizedAccountId] = entry - cfg.channels[storageKey] = current + mergeMessagingAccountEntry(cfg, storageKey, normalizedAccountId, entry) } const entry = { enabled: true } if (platform === 'qqbot') { @@ -4547,16 +4615,12 @@ const handlers = { } else { Object.assign(entry, form) preserveMessagingCredentialRefs(entry, form, currentSaved) - setRootChannelEntry(entry) } if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector') { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 - const existing = cfg.channels[storageKey] - cfg.channels[storageKey] = (existing && typeof existing === 'object') - ? { ...existing, ...entry } - : entry + applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) // Discord: 仅在首次创建时设置默认值,不覆盖用户已有的设置 if (platform === 'discord') { const d = cfg.channels[storageKey] diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 9263f75..b6986d3 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -441,6 +441,49 @@ fn merge_channel_entry( channels_map.insert(key.to_string(), Value::Object(merged)); } +/// 合并账号级渠道配置:保留渠道根节点和账号已有自定义字段,只覆盖本次表单字段。 +fn merge_account_channel_entry( + channels_map: &mut Map, + key: &str, + account_id: &str, + new_entry: Map, +) -> Result<(), String> { + let channel = channels_map + .entry(key.to_string()) + .or_insert_with(|| json!({ "enabled": true })); + let channel_obj = channel + .as_object_mut() + .ok_or(format!("{} 节点格式错误", key))?; + channel_obj.insert("enabled".into(), Value::Bool(true)); + let accounts = channel_obj.entry("accounts").or_insert_with(|| json!({})); + let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?; + let merged = if let Some(Value::Object(existing)) = accounts_obj.get(account_id) { + let mut m = existing.clone(); + for (k, v) in new_entry { + m.insert(k, v); + } + m + } else { + new_entry + }; + accounts_obj.insert(account_id.to_string(), Value::Object(merged)); + Ok(()) +} + +fn merge_channel_entry_for_account( + channels_map: &mut Map, + key: &str, + account_id: Option<&str>, + new_entry: Map, +) -> Result<(), String> { + if let Some(acct) = account_id.map(str::trim).filter(|s| !s.is_empty()) { + merge_account_channel_entry(channels_map, key, acct, new_entry) + } else { + merge_channel_entry(channels_map, key, new_entry); + Ok(()) + } +} + fn normalize_binding_match_value(value: &Value) -> Option { match value { Value::Null => None, @@ -1034,7 +1077,7 @@ pub async fn save_messaging_platform( // 合并到现有配置,保留用户通过 CLI 设置的 streaming / retry / dmPolicy 等 preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, "discord", entry); + merge_channel_entry_for_account(channels_map, "discord", account_id.as_deref(), entry)?; // 仅在首次创建时设置默认值,不覆盖用户已有的设置 if let Some(Value::Object(d)) = channels_map.get_mut("discord") { d.entry("groupPolicy") @@ -1064,7 +1107,12 @@ pub async fn save_messaging_platform( put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, "telegram", entry); + merge_channel_entry_for_account( + channels_map, + "telegram", + account_id.as_deref(), + entry, + )?; } "qqbot" => { let app_id = form_obj @@ -1180,23 +1228,12 @@ pub async fn save_messaging_platform( put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - // 多账号模式:写入 channels..accounts. - if let Some(ref acct) = account_id { - if !acct.is_empty() { - let feishu = channels_map - .entry(storage_key.as_str()) - .or_insert_with(|| json!({ "enabled": true })); - let feishu_obj = feishu.as_object_mut().ok_or("飞书节点格式错误")?; - feishu_obj.entry("enabled").or_insert(Value::Bool(true)); - let accounts = feishu_obj.entry("accounts").or_insert_with(|| json!({})); - let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?; - accounts_obj.insert(acct.clone(), Value::Object(entry)); - } else { - merge_channel_entry(channels_map, &storage_key, entry); - } - } else { - merge_channel_entry(channels_map, &storage_key, entry); - } + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; ensure_plugin_allowed(&mut cfg, "openclaw-lark")?; // 禁用旧版 feishu 插件,防止新旧插件同时运行冲突 disable_legacy_plugin(&mut cfg, "feishu"); @@ -1248,7 +1285,12 @@ pub async fn save_messaging_platform( } preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?; ensure_chat_completions_enabled(&mut cfg)?; let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector"); @@ -1304,7 +1346,12 @@ pub async fn save_messaging_platform( ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; } "whatsapp" => { let mut entry = Map::new(); @@ -1317,7 +1364,12 @@ pub async fn save_messaging_platform( ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); put_bool_from_form(&mut entry, "enabled", &form_string(form_obj, "enabled")); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; } "signal" => { let account = form_string(form_obj, "account"); @@ -1340,7 +1392,12 @@ pub async fn save_messaging_platform( ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; } "matrix" => { let homeserver = form_string(form_obj, "homeserver"); @@ -1371,7 +1428,12 @@ pub async fn save_messaging_platform( put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee")); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; ensure_plugin_allowed(&mut cfg, "matrix")?; } "msteams" => { @@ -1405,7 +1467,12 @@ pub async fn save_messaging_platform( put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; ensure_plugin_allowed(&mut cfg, "msteams")?; } _ => { @@ -1416,7 +1483,12 @@ pub async fn save_messaging_platform( } entry.insert("enabled".into(), Value::Bool(true)); preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); - merge_channel_entry(channels_map, &storage_key, entry); + merge_channel_entry_for_account( + channels_map, + &storage_key, + account_id.as_deref(), + entry, + )?; } } diff --git a/src/pages/channels.js b/src/pages/channels.js index 334fcf9..d28d84f 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -436,8 +436,12 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── -// ── 多账号支持的平台(历史配置中飞书/钉钉等多实例仍展示子账号行) ── -const MULTI_INSTANCE_PLATFORMS = ['feishu', 'dingtalk', 'qqbot'] +// ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot'] + +function supportsMessagingMultiAccount(pid) { + return MULTI_INSTANCE_PLATFORMS.includes(pid) +} function platformLabel(pid) { return PLATFORM_REGISTRY[pid]?.label || CHANNEL_LABELS[pid] || pid @@ -586,7 +590,7 @@ function renderConfigured(page, state) { const runtimeSummary = getChannelRuntimeSummary(state.runtimeStatus, channelKey, label) const accounts = Array.isArray(p.accounts) ? p.accounts : [] const hasAccounts = accounts.length > 0 - const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(p.id) + const supportsMulti = supportsMessagingMultiAccount(p.id) if (hasAccounts) { const accountsHtml = accounts.map(acc => { @@ -672,7 +676,7 @@ function renderConfigured(page, state) { const accounts = Array.isArray(configured.accounts) ? configured.accounts : [] const hasAccounts = accounts.length > 0 - const supportsMulti = MULTI_INSTANCE_PLATFORMS.includes(pid) + const supportsMulti = supportsMessagingMultiAccount(pid) // 统计当前 channel+accountId 组合已有的 agent 绑定 const channelKey = getChannelBindingKey(pid) @@ -1921,7 +1925,7 @@ async function openConfigDialog(pid, page, state, accountId) { const formId = 'platform-form-' + Date.now() - const supportsMultiAccount = ['feishu', 'dingtalk', 'dingtalk-connector', 'qqbot'].includes(pid) + const supportsMultiAccount = supportsMessagingMultiAccount(pid) // 账号标识(多账号);编辑时 accountId 非空会在 input value 中显示 const accountIdHtml = supportsMultiAccount ? ` diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 4f4b2ff..54fdbf6 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -4,6 +4,7 @@ import assert from 'node:assert/strict' import { buildMessagingPlatformFormValues, listPlatformAccounts, + mergeOpenClawMessagingPlatformConfig, resolveMessagingCredentialValueForSave, normalizeMessagingPlatformForm, } from '../scripts/dev-api.js' @@ -224,3 +225,53 @@ test('渠道保存时 clientId 未改动 SecretRef 占位会保留原始引用', assert.deepEqual(value, secretRef) }) + +test('OpenClaw 渠道保存带账号标识时会写入 accounts 而不是覆盖根配置', () => { + const cfg = { + channels: { + telegram: { + enabled: true, + botToken: 'root-token', + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + }, + discord: { + enabled: true, + token: 'root-discord', + groupPolicy: 'allowlist', + }, + slack: { + enabled: true, + mode: 'socket', + botToken: 'root-slack', + appToken: 'root-app', + }, + }, + } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'telegram', + accountId: 'alerts', + form: { botToken: 'alerts-token', dmPolicy: 'allowlist', groupPolicy: 'disabled' }, + }) + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'discord', + accountId: 'ops', + form: { token: 'ops-discord', guildId: 'guild-1', channelId: 'channel-1' }, + }) + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'slack', + accountId: 'team-a', + form: { mode: 'socket', botToken: 'team-slack', appToken: 'team-app' }, + }) + + assert.equal(cfg.channels.telegram.botToken, 'root-token') + assert.equal(cfg.channels.telegram.accounts.alerts.botToken, 'alerts-token') + assert.equal(cfg.channels.telegram.accounts.alerts.dmPolicy, 'allowlist') + assert.equal(cfg.channels.discord.token, 'root-discord') + assert.equal(cfg.channels.discord.accounts.ops.token, 'ops-discord') + assert.equal(cfg.channels.discord.accounts.ops.guilds['guild-1'].channels['channel-1'].allow, true) + assert.equal(cfg.channels.slack.botToken, 'root-slack') + assert.equal(cfg.channels.slack.accounts['team-a'].botToken, 'team-slack') + assert.equal(cfg.channels.slack.accounts['team-a'].appToken, 'team-app') +})