From 050ee1085016a6270ce63fd39a2722132291bfbe Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Wed, 11 Mar 2026 18:44:55 +0800 Subject: [PATCH] feat(channels): enhance channel configuration with account support and improve agent handling (#420) --- electron/api/routes/agents.ts | 9 +- electron/api/routes/channels.ts | 9 +- electron/utils/agent-config.ts | 157 ++++++++-- electron/utils/channel-config.ts | 295 +++++++++++++----- .../channels/ChannelConfigModal.tsx | 10 +- src/pages/Agents/index.tsx | 1 + src/pages/Channels/index.tsx | 18 +- 7 files changed, 388 insertions(+), 111 deletions(-) diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts index 0444e2d1e..e483dee64 100644 --- a/electron/api/routes/agents.ts +++ b/electron/api/routes/agents.ts @@ -5,9 +5,10 @@ import { createAgent, deleteAgentConfig, listAgentsSnapshot, + resolveAccountIdForAgent, updateAgentName, } from '../../utils/agent-config'; -import { deleteChannelConfig } from '../../utils/channel-config'; +import { deleteChannelAccountConfig } from '../../utils/channel-config'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -91,9 +92,11 @@ export async function handleAgentRoutes( if (parts.length === 3 && parts[1] === 'channels') { try { + const agentId = decodeURIComponent(parts[0]); const channelType = decodeURIComponent(parts[2]); - await deleteChannelConfig(channelType); - const snapshot = await clearChannelBinding(channelType); + const accountId = resolveAccountIdForAgent(agentId); + await deleteChannelAccountConfig(channelType, accountId); + const snapshot = await clearChannelBinding(channelType, accountId); scheduleGatewayReload(ctx, 'remove-agent-channel'); sendJson(res, 200, { success: true, ...snapshot }); } catch (error) { diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 29f742086..49d482956 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -12,6 +12,7 @@ import { validateChannelConfig, validateChannelCredentials, } from '../../utils/channel-config'; +import { clearAllBindingsForChannel } from '../../utils/agent-config'; import { whatsAppLoginManager } from '../../utils/whatsapp-login'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; @@ -242,7 +243,7 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/config' && req.method === 'POST') { try { - const body = await parseJsonBody<{ channelType: string; config: Record }>(req); + const body = await parseJsonBody<{ channelType: string; config: Record; accountId?: string }>(req); if (body.channelType === 'dingtalk') { const installResult = await ensureDingTalkPluginInstalled(); if (!installResult.installed) { @@ -271,7 +272,7 @@ export async function handleChannelRoutes( return true; } } - await saveChannelConfig(body.channelType, body.config); + await saveChannelConfig(body.channelType, body.config, body.accountId); scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`); sendJson(res, 200, { success: true }); } catch (error) { @@ -295,9 +296,10 @@ export async function handleChannelRoutes( if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') { try { const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); + const accountId = url.searchParams.get('accountId') || undefined; sendJson(res, 200, { success: true, - values: await getChannelFormValues(channelType), + values: await getChannelFormValues(channelType, accountId), }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); @@ -309,6 +311,7 @@ export async function handleChannelRoutes( try { const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); await deleteChannelConfig(channelType); + await clearAllBindingsForChannel(channelType); scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`); sendJson(res, 200, { success: true }); } catch (error) { diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index c83444b83..8220db559 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -1,12 +1,13 @@ import { access, copyFile, mkdir, readdir, rm } from 'fs/promises'; import { constants } from 'fs'; import { join, normalize } from 'path'; -import { listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { expandPath, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; const MAIN_AGENT_ID = 'main'; const MAIN_AGENT_NAME = 'Main'; +const DEFAULT_ACCOUNT_ID = 'default'; const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace'; const AGENT_BOOTSTRAP_FILES = [ 'AGENTS.md', @@ -49,6 +50,7 @@ interface AgentsConfig extends Record { interface BindingMatch extends Record { channel?: string; + accountId?: string; } interface BindingConfig extends Record { @@ -56,9 +58,16 @@ interface BindingConfig extends Record { match?: BindingMatch; } +interface ChannelSectionConfig extends Record { + accounts?: Record>; + defaultAccount?: string; + enabled?: boolean; +} + interface AgentConfigDocument extends Record { agents?: AgentsConfig; bindings?: BindingConfig[]; + channels?: Record; session?: { mainKey?: string; [key: string]: unknown; @@ -192,13 +201,17 @@ function normalizeAgentsConfig(config: AgentConfigDocument): { }; } -function isSimpleChannelBinding(binding: unknown): binding is BindingConfig { +function isChannelBinding(binding: unknown): binding is BindingConfig { if (!binding || typeof binding !== 'object') return false; const candidate = binding as BindingConfig; if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false; if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false; + if (typeof candidate.match.channel !== 'string' || !candidate.match.channel) return false; const keys = Object.keys(candidate.match); - return keys.length === 1 && typeof candidate.match.channel === 'string' && Boolean(candidate.match.channel); + // Accept bindings with just {channel} or {channel, accountId} + if (keys.length === 1 && keys[0] === 'channel') return true; + if (keys.length === 2 && keys.includes('channel') && keys.includes('accountId')) return true; + return false; } /** Normalize agent ID for consistent comparison (bindings vs entries). */ @@ -216,36 +229,61 @@ function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string): return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`; } -function getSimpleChannelBindingMap(bindings: unknown): Map { - const owners = new Map(); - if (!Array.isArray(bindings)) return owners; +/** + * Returns a map of channelType -> agentId from bindings. + * Account-scoped bindings are preferred; channel-wide bindings serve as fallback. + * Multiple agents can own the same channel type (different accounts). + */ +function getChannelBindingMap(bindings: unknown): { + channelToAgent: Map; + accountToAgent: Map; +} { + const channelToAgent = new Map(); + const accountToAgent = new Map(); + if (!Array.isArray(bindings)) return { channelToAgent, accountToAgent }; for (const binding of bindings) { - if (!isSimpleChannelBinding(binding)) continue; + if (!isChannelBinding(binding)) continue; const agentId = normalizeAgentIdForBinding(binding.agentId!); const channel = binding.match?.channel; - if (agentId && channel) owners.set(channel, agentId); + if (!agentId || !channel) continue; + + const accountId = binding.match?.accountId; + if (accountId) { + accountToAgent.set(`${channel}:${accountId}`, agentId); + } else { + channelToAgent.set(channel, agentId); + } } - return owners; + return { channelToAgent, accountToAgent }; } function upsertBindingsForChannel( bindings: unknown, channelType: string, agentId: string | null, + accountId?: string, ): BindingConfig[] | undefined { const nextBindings = Array.isArray(bindings) - ? [...bindings as BindingConfig[]].filter((binding) => !( - isSimpleChannelBinding(binding) && binding.match?.channel === channelType - )) + ? [...bindings as BindingConfig[]].filter((binding) => { + if (!isChannelBinding(binding)) return true; + if (binding.match?.channel !== channelType) return true; + // Only remove binding that matches the exact accountId scope + if (accountId) { + return binding.match?.accountId !== accountId; + } + // No accountId: remove channel-wide binding (legacy) + return Boolean(binding.match?.accountId); + }) : []; if (agentId) { - nextBindings.push({ - agentId, - match: { channel: channelType }, - }); + const match: BindingMatch = { channel: channelType }; + if (accountId) { + match.accountId = accountId; + } + nextBindings.push({ agentId, match }); } return nextBindings.length > 0 ? nextBindings : undefined; @@ -360,15 +398,67 @@ async function provisionAgentFilesystem(config: AgentConfigDocument, agent: Agen } } +export function resolveAccountIdForAgent(agentId: string): string { + return agentId === MAIN_AGENT_ID ? DEFAULT_ACCOUNT_ID : agentId; +} + +function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channelType: string): string[] { + const channelSection = config.channels?.[channelType]; + if (!channelSection || channelSection.enabled === false) { + return []; + } + + const accounts = channelSection.accounts; + if (!accounts || typeof accounts !== 'object' || Object.keys(accounts).length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + + return Object.keys(accounts) + .filter(Boolean) + .sort((a, b) => { + if (a === DEFAULT_ACCOUNT_ID) return -1; + if (b === DEFAULT_ACCOUNT_ID) return 1; + return a.localeCompare(b); + }); +} + async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise { const { entries, defaultAgentId } = normalizeAgentsConfig(config); const configuredChannels = await listConfiguredChannels(); - const explicitOwners = getSimpleChannelBindingMap(config.bindings); + const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings); const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId); const channelOwners: Record = {}; + // Build per-agent channel lists from account-scoped bindings + const agentChannelSets = new Map>(); + for (const channelType of configuredChannels) { - channelOwners[channelType] = explicitOwners.get(channelType) || defaultAgentIdNorm; + const accountIds = listConfiguredAccountIdsForChannel(config, channelType); + let primaryOwner: string | undefined; + + for (const accountId of accountIds) { + const owner = + accountToAgent.get(`${channelType}:${accountId}`) + || (accountId === DEFAULT_ACCOUNT_ID ? (channelToAgent.get(channelType) || defaultAgentIdNorm) : undefined); + + if (!owner) { + continue; + } + + primaryOwner ??= owner; + const existing = agentChannelSets.get(owner) ?? new Set(); + existing.add(channelType); + agentChannelSets.set(owner, existing); + } + + if (!primaryOwner) { + primaryOwner = channelToAgent.get(channelType) || defaultAgentIdNorm; + const existing = agentChannelSets.get(primaryOwner) ?? new Set(); + existing.add(channelType); + agentChannelSets.set(primaryOwner, existing); + } + + channelOwners[channelType] = primaryOwner; } const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model); @@ -376,6 +466,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise(); return { id: entry.id, name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id), @@ -385,7 +476,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise channelOwners[channelType] === entryIdNorm), + channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)), }; }); @@ -489,7 +580,7 @@ export async function deleteAgentConfig(agentId: string): Promise !(isSimpleChannelBinding(binding) && binding.agentId === agentId)) + ? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId)) : undefined; if (defaultAgentId === agentId && nextEntries.length > 0) { @@ -500,6 +591,7 @@ export async function deleteAgentConfig(agentId: string): Promise { +export async function clearChannelBinding(channelType: string, accountId?: string): Promise { const config = await readOpenClawConfig() as AgentConfigDocument; - config.bindings = upsertBindingsForChannel(config.bindings, channelType, null); + config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId); await writeOpenClawConfig(config); - logger.info('Cleared simplified channel binding', { channelType }); + logger.info('Cleared channel binding', { channelType, accountId }); return buildSnapshotFromConfig(config); } + +export async function clearAllBindingsForChannel(channelType: string): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + if (!Array.isArray(config.bindings)) return; + + const nextBindings = config.bindings.filter((binding) => { + if (!isChannelBinding(binding)) return true; + return binding.match?.channel !== channelType; + }); + + config.bindings = nextBindings.length > 0 ? nextBindings : undefined; + await writeOpenClawConfig(config); + logger.info('Cleared all bindings for channel', { channelType }); +} diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 161708859..b7c79c617 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -15,6 +15,9 @@ import { proxyAwareFetch } from './proxy-fetch'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); const WECOM_PLUGIN_ID = 'wecom-openclaw-plugin'; +const FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin'; +const DEFAULT_ACCOUNT_ID = 'default'; +const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']); // Channels that are managed as plugins (config goes under plugins.entries, not channels) const PLUGIN_CHANNELS = ['whatsapp']; @@ -93,14 +96,8 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise // ── Channel operations ─────────────────────────────────────────── -export async function saveChannelConfig( - channelType: string, - config: ChannelConfigData -): Promise { - const currentConfig = await readOpenClawConfig(); - +function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): void { if (channelType === 'feishu') { - const FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin'; if (!currentConfig.plugins) { currentConfig.plugins = { allow: [FEISHU_PLUGIN_ID], @@ -115,17 +112,13 @@ export async function saveChannelConfig( const allow: string[] = Array.isArray(currentConfig.plugins.allow) ? (currentConfig.plugins.allow as string[]) : []; - - // Remove legacy 'feishu' plugin from allowlist const normalizedAllow = allow.filter((pluginId) => pluginId !== 'feishu'); - if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) { currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID]; } else if (normalizedAllow.length !== allow.length) { currentConfig.plugins.allow = normalizedAllow; } - // Explicitly disable the legacy plugin and enable the official one if (!currentConfig.plugins.entries) { currentConfig.plugins.entries = {}; } @@ -141,12 +134,9 @@ export async function saveChannelConfig( } } - // DingTalk is a channel plugin; make sure it's explicitly allowed. - // Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty. if (channelType === 'dingtalk') { - const defaultDingtalkAllow = ['dingtalk']; if (!currentConfig.plugins) { - currentConfig.plugins = { allow: defaultDingtalkAllow, enabled: true }; + currentConfig.plugins = { allow: ['dingtalk'], enabled: true }; } else { currentConfig.plugins.enabled = true; const allow: string[] = Array.isArray(currentConfig.plugins.allow) @@ -159,9 +149,8 @@ export async function saveChannelConfig( } if (channelType === 'wecom') { - const defaultWecomAllow = [WECOM_PLUGIN_ID]; if (!currentConfig.plugins) { - currentConfig.plugins = { allow: defaultWecomAllow, enabled: true }; + currentConfig.plugins = { allow: [WECOM_PLUGIN_ID], enabled: true }; } else { currentConfig.plugins.enabled = true; const allow: string[] = Array.isArray(currentConfig.plugins.allow) @@ -176,8 +165,6 @@ export async function saveChannelConfig( } } - // QQ Bot is a channel plugin; make sure it's explicitly allowed. - // Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty. if (channelType === 'qqbot') { if (!currentConfig.plugins) { currentConfig.plugins = {}; @@ -190,37 +177,15 @@ export async function saveChannelConfig( currentConfig.plugins.allow = [...allow, 'qqbot']; } } +} - // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels - if (PLUGIN_CHANNELS.includes(channelType)) { - if (!currentConfig.plugins) { - currentConfig.plugins = {}; - } - if (!currentConfig.plugins.entries) { - currentConfig.plugins.entries = {}; - } - currentConfig.plugins.entries[channelType] = { - ...currentConfig.plugins.entries[channelType], - enabled: config.enabled ?? true, - }; - await writeOpenClawConfig(currentConfig); - logger.info('Plugin channel config saved', { - channelType, - configFile: CONFIG_FILE, - path: `plugins.entries.${channelType}`, - }); - console.log(`Saved plugin channel config for ${channelType}`); - return; - } - - if (!currentConfig.channels) { - currentConfig.channels = {}; - } - - // Transform config to match OpenClaw expected format +function transformChannelConfig( + channelType: string, + config: ChannelConfigData, + existingAccountConfig: ChannelConfigData, +): ChannelConfigData { let transformedConfig: ChannelConfigData = { ...config }; - // Special handling for Discord: convert guildId/channelId to complete structure if (channelType === 'discord') { const { guildId, channelId, ...restConfig } = config; transformedConfig = { ...restConfig }; @@ -256,7 +221,6 @@ export async function saveChannelConfig( } } - // Special handling for Telegram: convert allowedUsers string to allowlist array if (channelType === 'telegram') { const { allowedUsers, ...restConfig } = config; transformedConfig = { ...restConfig }; @@ -272,13 +236,11 @@ export async function saveChannelConfig( } } - // Special handling for Feishu / WeCom: default to open DM policy with wildcard allowlist if (channelType === 'feishu' || channelType === 'wecom') { - const existingConfig = currentConfig.channels[channelType] || {}; - const existingDmPolicy = existingConfig.dmPolicy === 'pairing' ? 'open' : existingConfig.dmPolicy; + const existingDmPolicy = existingAccountConfig.dmPolicy === 'pairing' ? 'open' : existingAccountConfig.dmPolicy; transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open'; - let allowFrom = (transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*']) as string[]; + let allowFrom = (transformedConfig.allowFrom ?? existingAccountConfig.allowFrom ?? ['*']) as string[]; if (!Array.isArray(allowFrom)) { allowFrom = [allowFrom] as string[]; } @@ -290,9 +252,122 @@ export async function saveChannelConfig( transformedConfig.allowFrom = allowFrom; } - // Merge with existing config - currentConfig.channels[channelType] = { - ...currentConfig.channels[channelType], + return transformedConfig; +} + +function resolveAccountConfig( + channelSection: ChannelConfigData | undefined, + accountId: string, +): ChannelConfigData { + if (!channelSection) return {}; + const accounts = channelSection.accounts as Record | undefined; + return accounts?.[accountId] ?? {}; +} + +function getLegacyChannelPayload(channelSection: ChannelConfigData): ChannelConfigData { + const payload: ChannelConfigData = {}; + for (const [key, value] of Object.entries(channelSection)) { + if (CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key)) continue; + payload[key] = value; + } + return payload; +} + +function migrateLegacyChannelConfigToAccounts( + channelSection: ChannelConfigData, + defaultAccountId: string = DEFAULT_ACCOUNT_ID, +): void { + const legacyPayload = getLegacyChannelPayload(channelSection); + const legacyKeys = Object.keys(legacyPayload); + const hasAccounts = + Boolean(channelSection.accounts) && + typeof channelSection.accounts === 'object' && + Object.keys(channelSection.accounts as Record).length > 0; + + if (legacyKeys.length === 0) { + if (hasAccounts && typeof channelSection.defaultAccount !== 'string') { + channelSection.defaultAccount = defaultAccountId; + } + return; + } + + if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { + channelSection.accounts = {}; + } + const accounts = channelSection.accounts as Record; + const existingDefaultAccount = accounts[defaultAccountId] ?? {}; + + accounts[defaultAccountId] = { + ...(channelSection.enabled !== undefined ? { enabled: channelSection.enabled } : {}), + ...legacyPayload, + ...existingDefaultAccount, + }; + + channelSection.defaultAccount = + typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount + : defaultAccountId; + + for (const key of legacyKeys) { + delete channelSection[key]; + } +} + +export async function saveChannelConfig( + channelType: string, + config: ChannelConfigData, + accountId?: string, +): Promise { + const currentConfig = await readOpenClawConfig(); + const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; + + ensurePluginAllowlist(currentConfig, channelType); + + // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels + if (PLUGIN_CHANNELS.includes(channelType)) { + if (!currentConfig.plugins) { + currentConfig.plugins = {}; + } + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + currentConfig.plugins.entries[channelType] = { + ...currentConfig.plugins.entries[channelType], + enabled: config.enabled ?? true, + }; + await writeOpenClawConfig(currentConfig); + logger.info('Plugin channel config saved', { + channelType, + configFile: CONFIG_FILE, + path: `plugins.entries.${channelType}`, + }); + console.log(`Saved plugin channel config for ${channelType}`); + return; + } + + if (!currentConfig.channels) { + currentConfig.channels = {}; + } + if (!currentConfig.channels[channelType]) { + currentConfig.channels[channelType] = {}; + } + + const channelSection = currentConfig.channels[channelType]; + migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); + const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId); + const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig); + + // Write credentials into accounts. + if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { + channelSection.accounts = {}; + } + const accounts = channelSection.accounts as Record; + channelSection.defaultAccount = + typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount + : DEFAULT_ACCOUNT_ID; + accounts[resolvedAccountId] = { + ...accounts[resolvedAccountId], ...transformedConfig, enabled: transformedConfig.enabled ?? true, }; @@ -300,23 +375,34 @@ export async function saveChannelConfig( await writeOpenClawConfig(currentConfig); logger.info('Channel config saved', { channelType, + accountId: resolvedAccountId, configFile: CONFIG_FILE, rawKeys: Object.keys(config), transformedKeys: Object.keys(transformedConfig), - enabled: currentConfig.channels[channelType]?.enabled, }); - console.log(`Saved channel config for ${channelType}`); + console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`); } -export async function getChannelConfig(channelType: string): Promise { +export async function getChannelConfig(channelType: string, accountId?: string): Promise { const config = await readOpenClawConfig(); - return config.channels?.[channelType]; + const channelSection = config.channels?.[channelType]; + if (!channelSection) return undefined; + + const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; + const accounts = channelSection.accounts as Record | undefined; + if (accounts?.[resolvedAccountId]) { + return accounts[resolvedAccountId]; + } + + // Backward compat: fall back to flat top-level config (legacy format without accounts) + if (!accounts || Object.keys(accounts).length === 0) { + return channelSection; + } + + return undefined; } -export async function getChannelFormValues(channelType: string): Promise | undefined> { - const saved = await getChannelConfig(channelType); - if (!saved) return undefined; - +function extractFormValues(channelType: string, saved: ChannelConfigData): Record { const values: Record = {}; if (channelType === 'discord') { @@ -355,9 +441,37 @@ export async function getChannelFormValues(channelType: string): Promise | undefined> { + const saved = await getChannelConfig(channelType, accountId); + if (!saved) return undefined; + + const values = extractFormValues(channelType, saved); return Object.keys(values).length > 0 ? values : undefined; } +export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise { + const currentConfig = await readOpenClawConfig(); + const channelSection = currentConfig.channels?.[channelType]; + if (!channelSection) return; + + migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); + const accounts = channelSection.accounts as Record | undefined; + if (!accounts?.[accountId]) return; + + delete accounts[accountId]; + + if (Object.keys(accounts).length === 0) { + delete currentConfig.channels![channelType]; + } + + await writeOpenClawConfig(currentConfig); + logger.info('Deleted channel account config', { channelType, accountId }); + console.log(`Deleted channel account config for ${channelType}/${accountId}`); +} + export async function deleteChannelConfig(channelType: string): Promise { const currentConfig = await readOpenClawConfig(); @@ -379,7 +493,6 @@ export async function deleteChannelConfig(channelType: string): Promise { } } - // Special handling for WhatsApp credentials if (channelType === 'whatsapp') { try { const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); @@ -393,17 +506,28 @@ export async function deleteChannelConfig(channelType: string): Promise { } } +function channelHasAnyAccount(channelSection: ChannelConfigData): boolean { + const accounts = channelSection.accounts as Record | undefined; + if (accounts && typeof accounts === 'object') { + return Object.values(accounts).some((acc) => acc.enabled !== false); + } + return false; +} + export async function listConfiguredChannels(): Promise { const config = await readOpenClawConfig(); const channels: string[] = []; if (config.channels) { - channels.push(...Object.keys(config.channels).filter( - (channelType) => config.channels![channelType]?.enabled !== false - )); + for (const channelType of Object.keys(config.channels)) { + const section = config.channels[channelType]; + if (section.enabled === false) continue; + if (channelHasAnyAccount(section) || Object.keys(section).length > 0) { + channels.push(channelType); + } + } } - // Check for WhatsApp credentials directory try { const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); if (await fileExists(whatsappDir)) { @@ -429,6 +553,32 @@ export async function listConfiguredChannels(): Promise { return channels; } +export async function deleteAgentChannelAccounts(agentId: string): Promise { + const currentConfig = await readOpenClawConfig(); + if (!currentConfig.channels) return; + + const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; + let modified = false; + + for (const channelType of Object.keys(currentConfig.channels)) { + const section = currentConfig.channels[channelType]; + migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); + const accounts = section.accounts as Record | undefined; + if (!accounts?.[accountId]) continue; + + delete accounts[accountId]; + if (Object.keys(accounts).length === 0) { + delete currentConfig.channels[channelType]; + } + modified = true; + } + + if (modified) { + await writeOpenClawConfig(currentConfig); + logger.info('Deleted all channel accounts for agent', { agentId, accountId }); + } +} + export async function setChannelEnabled(channelType: string, enabled: boolean): Promise { const currentConfig = await readOpenClawConfig(); @@ -625,21 +775,22 @@ export async function validateChannelConfig(channelType: string): Promise void; onChannelSaved?: (channelType: ChannelType) => void | Promise; } @@ -61,6 +62,7 @@ export function ChannelConfigModal({ configuredTypes = [], showChannelName = true, allowExistingConfig = true, + agentId, onClose, onChannelSaved, }: ChannelConfigModalProps) { @@ -115,8 +117,9 @@ export function ChannelConfigModal({ (async () => { try { + const accountParam = agentId ? `?accountId=${encodeURIComponent(agentId === 'main' ? 'default' : agentId)}` : ''; const result = await hostApiFetch<{ success: boolean; values?: Record }>( - `/api/channels/config/${encodeURIComponent(selectedType)}` + `/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}` ); if (cancelled) return; @@ -140,7 +143,7 @@ export function ChannelConfigModal({ return () => { cancelled = true; }; - }, [allowExistingConfig, configuredTypes, selectedType, showChannelName]); + }, [agentId, allowExistingConfig, configuredTypes, selectedType, showChannelName]); useEffect(() => { if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) { @@ -312,13 +315,14 @@ export function ChannelConfigModal({ } const config: Record = { ...configValues }; + const resolvedAccountId = agentId ? (agentId === 'main' ? 'default' : agentId) : undefined; const saveResult = await hostApiFetch<{ success?: boolean; error?: string; warning?: string; }>('/api/channels/config', { method: 'POST', - body: JSON.stringify({ channelType: selectedType, config }), + body: JSON.stringify({ channelType: selectedType, config, accountId: resolvedAccountId }), }); if (!saveResult?.success) { throw new Error(saveResult?.error || 'Failed to save channel config'); diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 1e7bb2cb7..0839ec757 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -535,6 +535,7 @@ function AgentSettingsModal({ configuredTypes={agent.channelTypes} showChannelName={false} allowExistingConfig + agentId={agent.id} onClose={() => setShowChannelModal(false)} onChannelSaved={async (channelType) => { await handleChannelSaved(channelType); diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 634bd0bd2..2bad99d45 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -94,6 +94,15 @@ export function Channels() { } const safeChannels = Array.isArray(channels) ? channels : []; + const configuredPlaceholderChannels: Channel[] = displayedChannelTypes + .filter((type) => configuredTypes.includes(type) && !safeChannels.some((channel) => channel.type === type)) + .map((type) => ({ + id: `${type}-default`, + type, + name: CHANNEL_NAMES[type] || CHANNEL_META[type].name, + status: 'disconnected', + })); + const availableChannels = [...safeChannels, ...configuredPlaceholderChannels]; return (
@@ -140,13 +149,13 @@ export function Channels() {
)} - {safeChannels.length > 0 && ( + {availableChannels.length > 0 && (

{t('availableChannels')}

- {safeChannels.map((channel) => ( + {availableChannels.map((channel) => ( {displayedChannelTypes.map((type) => { const meta = CHANNEL_META[type]; - const isConfigured = safeChannels.some((channel) => channel.type === type) - || configuredTypes.includes(type); - if (isConfigured) return null; + const isAvailable = availableChannels.some((channel) => channel.type === type); + if (isAvailable) return null; return (