diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 946af98a0..f07a9281a 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -317,6 +317,20 @@ interface ChannelAccountsView { accounts: ChannelAccountView[]; } +function shouldIncludeRuntimeAccountId( + accountId: string, + configuredAccountIds: Set, + runtimeAccount: { configured?: boolean }, +): boolean { + if (configuredAccountIds.has(accountId)) { + return true; + } + // Defensive filtering: channels.status can occasionally expose transient + // runtime rows for stale sessions. Only include runtime-only accounts when + // gateway marks them as configured. + return runtimeAccount.configured === true; +} + interface ChannelTargetOptionView { value: string; label: string; @@ -375,6 +389,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise account.accountId) - .filter((accountId): accountId is string => typeof accountId === 'string' && accountId.trim().length > 0); + const runtimeAccountIds = runtimeAccounts.reduce((acc, account) => { + const accountId = typeof account.accountId === 'string' ? account.accountId.trim() : ''; + if (!accountId) { + return acc; + } + if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) { + return acc; + } + acc.push(accountId); + return acc; + }, []); const accountIds = Array.from(new Set([...channelAccountsFromConfig, ...runtimeAccountIds, defaultAccountId])); const accounts: ChannelAccountView[] = accountIds.map((accountId) => { diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index a72a19f65..fb5d5a244 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -139,7 +139,7 @@ function slugifyAgentId(name: string): string { .replace(/-+/g, '-') .replace(/^-|-$/g, ''); - if (!normalized) return 'agent'; + if (!normalized || /^\d+$/.test(normalized)) return 'agent'; if (normalized === MAIN_AGENT_ID) return 'agent'; return normalized; } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 3ab3d7193..1b708b5e9 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -200,11 +200,33 @@ function removePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin return modified; } +function getChannelAccountsMap( + channelSection: ChannelConfigData | undefined, +): Record | undefined { + if (!channelSection || typeof channelSection !== 'object') return undefined; + const accounts = channelSection.accounts; + if (!accounts || typeof accounts !== 'object' || Array.isArray(accounts)) { + return undefined; + } + return accounts as Record; +} + +function ensureChannelAccountsMap( + channelSection: ChannelConfigData, +): Record { + const accounts = getChannelAccountsMap(channelSection); + if (accounts) { + return accounts; + } + channelSection.accounts = {}; + return channelSection.accounts as Record; +} + function channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefined): boolean { if (!channelSection || typeof channelSection !== 'object') return false; - const accounts = channelSection.accounts as Record | undefined; - if (accounts && typeof accounts === 'object') { - return Object.keys(accounts).length > 0; + const accounts = getChannelAccountsMap(channelSection); + if (accounts) { + return Object.keys(accounts).some((accountId) => accountId.trim().length > 0); } return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key)); } @@ -578,7 +600,7 @@ function resolveAccountConfig( accountId: string, ): ChannelConfigData { if (!channelSection) return {}; - const accounts = channelSection.accounts as Record | undefined; + const accounts = getChannelAccountsMap(channelSection); return accounts?.[accountId] ?? {}; } @@ -597,10 +619,8 @@ function migrateLegacyChannelConfigToAccounts( ): 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; + const existingAccounts = getChannelAccountsMap(channelSection); + const hasAccounts = Boolean(existingAccounts) && Object.keys(existingAccounts).length > 0; if (legacyKeys.length === 0) { if (hasAccounts && typeof channelSection.defaultAccount !== 'string') { @@ -609,10 +629,7 @@ function migrateLegacyChannelConfigToAccounts( return; } - if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { - channelSection.accounts = {}; - } - const accounts = channelSection.accounts as Record; + const accounts = ensureChannelAccountsMap(channelSection); const existingDefaultAccount = accounts[defaultAccountId] ?? {}; accounts[defaultAccountId] = { @@ -657,7 +674,7 @@ function assertNoDuplicateCredential( }); } - const accounts = channelSection.accounts as Record | undefined; + const accounts = getChannelAccountsMap(channelSection); if (!accounts) return; for (const [existingAccountId, accountCfg] of Object.entries(accounts)) { @@ -737,10 +754,7 @@ export async function saveChannelConfig( } // Write credentials into accounts. - if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { - channelSection.accounts = {}; - } - const accounts = channelSection.accounts as Record; + const accounts = ensureChannelAccountsMap(channelSection); channelSection.defaultAccount = typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() ? channelSection.defaultAccount @@ -790,7 +804,7 @@ export async function getChannelConfig(channelType: string, accountId?: string): if (!channelSection) return undefined; const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; - const accounts = channelSection.accounts as Record | undefined; + const accounts = getChannelAccountsMap(channelSection); if (accounts?.[resolvedAccountId]) { return accounts[resolvedAccountId]; } @@ -868,7 +882,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId: } migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); - const accounts = channelSection.accounts as Record | undefined; + const accounts = getChannelAccountsMap(channelSection); if (!accounts?.[accountId]) return; delete accounts[accountId]; @@ -959,8 +973,8 @@ export async function deleteChannelConfig(channelType: string): Promise { } function channelHasAnyAccount(channelSection: ChannelConfigData): boolean { - const accounts = channelSection.accounts as Record | undefined; - if (accounts && typeof accounts === 'object') { + const accounts = getChannelAccountsMap(channelSection); + if (accounts) { return Object.values(accounts).some((acc) => acc.enabled !== false); } return false; @@ -1024,8 +1038,9 @@ export function listConfiguredChannelAccountsFromConfig(config: OpenClawConfig): for (const [channelType, section] of Object.entries(config.channels)) { if (!section || section.enabled === false) continue; - const accountIds = section.accounts && typeof section.accounts === 'object' - ? Object.keys(section.accounts).filter(Boolean) + const accounts = getChannelAccountsMap(section); + const accountIds = accounts + ? Object.keys(accounts).filter((accountId) => accountId.trim().length > 0) : []; let defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim() @@ -1082,7 +1097,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s } migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); - const accounts = channelSection.accounts as Record | undefined; + const accounts = getChannelAccountsMap(channelSection); if (!accounts || !accounts[trimmedAccountId]) { throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`); } @@ -1110,7 +1125,7 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc 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; + const accounts = getChannelAccountsMap(section); if (!accounts?.[accountId]) continue; if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) { continue; diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts index f6d556730..d939afb03 100644 --- a/tests/unit/agent-config.test.ts +++ b/tests/unit/agent-config.test.ts @@ -458,4 +458,25 @@ describe('agent config lifecycle', () => { expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined(); expect(snapshot.channelAccountOwners['telegram:default']).toBe('main'); }); + + it('avoids numeric-only ids when creating agents from CJK names', async () => { + await writeOpenClawJson({ + agents: { + list: [{ id: 'main', name: 'Main', default: true }], + }, + }); + + const { createAgent, listAgentsSnapshot } = await import('@electron/utils/agent-config'); + + await createAgent('测试2'); + await createAgent('测试1'); + + const snapshot = await listAgentsSnapshot(); + const agentIds = snapshot.agents.map((agent) => agent.id); + + expect(agentIds).toContain('agent'); + expect(agentIds).toContain('agent-2'); + expect(agentIds).not.toContain('2'); + expect(agentIds).not.toContain('1'); + }); }); diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index b66bb1bfe..b1e9862c7 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -250,3 +250,55 @@ describe('WeChat dangling plugin cleanup', () => { expect(existsSync(join(testHome, '.openclaw', 'openclaw-weixin'))).toBe(false); }); }); + +describe('configured channel account extraction', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('ignores malformed array-shaped accounts and falls back to default account', async () => { + const { listConfiguredChannelAccountsFromConfig } = await import('@electron/utils/channel-config'); + + const result = listConfiguredChannelAccountsFromConfig({ + channels: { + feishu: { + enabled: true, + defaultAccount: 'default', + accounts: [null, null, { appId: 'ghost-account' }], + appId: 'cli_real_app', + appSecret: 'real_secret', + }, + }, + }); + + expect(result.feishu).toEqual({ + defaultAccountId: 'default', + accountIds: ['default'], + }); + expect(result.feishu.accountIds).not.toContain('2'); + }); + + it('keeps intentionally configured numeric account ids from object-shaped accounts', async () => { + const { listConfiguredChannelAccountsFromConfig } = await import('@electron/utils/channel-config'); + + const result = listConfiguredChannelAccountsFromConfig({ + channels: { + feishu: { + enabled: true, + defaultAccount: '2', + accounts: { + '2': { enabled: true, appId: 'cli_numeric' }, + }, + }, + }, + }); + + expect(result.feishu).toEqual({ + defaultAccountId: '2', + accountIds: ['2'], + }); + }); +}); diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts index ee1259fa7..f377f5697 100644 --- a/tests/unit/channel-routes.test.ts +++ b/tests/unit/channel-routes.test.ts @@ -274,6 +274,85 @@ describe('handleChannelRoutes', () => { ); }); + it('filters runtime-only stale accounts when not configured locally', async () => { + listConfiguredChannelsMock.mockResolvedValue(['feishu']); + listConfiguredChannelAccountsMock.mockResolvedValue({ + feishu: { + defaultAccountId: 'default', + accountIds: ['default'], + }, + }); + readOpenClawConfigMock.mockResolvedValue({ + channels: { + feishu: { + defaultAccount: 'default', + }, + }, + }); + + const rpc = vi.fn().mockResolvedValue({ + channels: { + feishu: { + configured: true, + }, + }, + channelAccounts: { + feishu: [ + { + accountId: 'default', + configured: true, + connected: true, + running: true, + }, + { + accountId: '2', + configured: false, + connected: false, + running: false, + lastError: 'stale runtime session', + }, + ], + }, + channelDefaultAccountId: { + feishu: 'default', + }, + }); + + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + await handleChannelRoutes( + { method: 'GET' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/accounts'), + { + gatewayManager: { + rpc, + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + + expect(sendJsonMock).toHaveBeenCalledWith( + expect.anything(), + 200, + expect.objectContaining({ + success: true, + channels: [ + expect.objectContaining({ + channelType: 'feishu', + accounts: [expect.objectContaining({ accountId: 'default' })], + }), + ], + }), + ); + const payload = sendJsonMock.mock.calls.at(-1)?.[2] as { + channels?: Array<{ channelType: string; accounts: Array<{ accountId: string }> }>; + }; + const feishu = payload.channels?.find((entry) => entry.channelType === 'feishu'); + expect(feishu?.accounts.map((entry) => entry.accountId)).toEqual(['default']); + }); + it('lists known QQ Bot targets for a configured account', async () => { const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data'); mkdirSync(knownUsersPath, { recursive: true });