fix(channels): save multi-account platform configs

This commit is contained in:
晴天
2026-05-23 03:06:52 +08:00
parent f7518ae4b3
commit 01dff38a97
4 changed files with 241 additions and 50 deletions

View File

@@ -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]

View File

@@ -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<String, Value>,
key: &str,
account_id: &str,
new_entry: Map<String, Value>,
) -> 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<String, Value>,
key: &str,
account_id: Option<&str>,
new_entry: Map<String, Value>,
) -> 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<Value> {
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, &current_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, &current_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, &current_saved);
// 多账号模式:写入 channels.<storage_key>.accounts.<account_id>
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, &current_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, &current_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, &current_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, &current_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, &current_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, &current_saved);
merge_channel_entry(channels_map, &storage_key, entry);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
}
}

View File

@@ -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 ? `

View File

@@ -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')
})