diff --git a/README.ja-JP.md b/README.ja-JP.md index 70d249a6f..d0e660bc1 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -105,6 +105,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています ### 📡 マルチチャネル管理 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。 +カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。 ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。 ### ⏰ Cronベースの自動化 diff --git a/README.md b/README.md index 029900b58..f65ac7c6e 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Each agent can also override its own `provider/model` runtime setting; agents wi ### 📡 Multi-Channel Management Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page. +For custom channel account IDs, ClawX enforces OpenClaw-compatible canonical IDs (`[a-z0-9_-]`, lowercase, max 64 chars, must start with a letter/number) to prevent routing mismatches. ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow. ### ⏰ Cron-Based Automation diff --git a/README.zh-CN.md b/README.zh-CN.md index 1317c69d1..b0702981d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,6 +106,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 ### 📡 多频道管理 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。 +对于自定义频道账号 ID,ClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。 ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。 ### ⏰ 定时任务自动化 diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index f07a9281a..96ce52aaa 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -38,6 +38,7 @@ import { OPENCLAW_WECHAT_CHANNEL_TYPE, UI_WECHAT_CHANNEL_TYPE, buildQrChannelEventName, + isCanonicalOpenClawAccountId, toOpenClawChannelType, toUiChannelType, } from '../../utils/channel-alias'; @@ -89,6 +90,47 @@ function buildQrLoginKey(channelType: string, accountId?: string): string { return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`; } +async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise { + const config = await readOpenClawConfig(); + const configuredAccounts = listConfiguredChannelAccountsFromConfig(config) ?? {}; + const storedChannelType = resolveStoredChannelType(channelType); + const knownAccountIds = configuredAccounts[storedChannelType]?.accountIds ?? []; + return knownAccountIds.includes(accountId); +} + +async function validateCanonicalAccountId( + channelType: string, + accountId: string | undefined, + options?: { allowLegacyConfiguredId?: boolean }, +): Promise { + if (!accountId) return null; + const trimmed = accountId.trim(); + if (!trimmed) return 'accountId cannot be empty'; + if (isCanonicalOpenClawAccountId(trimmed)) { + return null; + } + if (options?.allowLegacyConfiguredId && await isLegacyConfiguredAccountId(channelType, trimmed)) { + return null; + } + // Backward compatibility note: + // existing legacy IDs can still be edited/bound if they already exist in config. + // new account IDs must be canonical to match OpenClaw runtime routing behavior. + return 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'; +} + +async function validateAccountIdOrReply( + res: ServerResponse, + channelType: string, + accountId: string | undefined, +): Promise { + const error = await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true }); + if (!error) { + return true; + } + sendJson(res, 400, { success: false, error }); + return false; +} + function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string { const loginKey = buildQrLoginKey(channelType, accountId); activeQrLogins.set(loginKey, sessionKey); @@ -1087,6 +1129,10 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') { try { const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); + const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); + if (!validAccountId) { + return true; + } await setChannelDefaultAccount(body.channelType, body.accountId); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`); sendJson(res, 200, { success: true }); @@ -1099,6 +1145,10 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/binding' && req.method === 'PUT') { try { const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req); + const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); + if (!validAccountId) { + return true; + } await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`); sendJson(res, 200, { success: true }); @@ -1111,6 +1161,10 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') { try { const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); + const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); + if (!validAccountId) { + return true; + } await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`); sendJson(res, 200, { success: true }); @@ -1212,6 +1266,10 @@ export async function handleChannelRoutes( if (url.pathname === '/api/channels/config' && req.method === 'POST') { try { const body = await parseJsonBody<{ channelType: string; config: Record; accountId?: string }>(req); + const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); + if (!validAccountId) { + return true; + } const storedChannelType = resolveStoredChannelType(body.channelType); if (storedChannelType === 'dingtalk') { const installResult = await ensureDingTalkPluginInstalled(); diff --git a/electron/utils/channel-alias.ts b/electron/utils/channel-alias.ts index 293f7a625..fb22af2ba 100644 --- a/electron/utils/channel-alias.ts +++ b/electron/utils/channel-alias.ts @@ -44,3 +44,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal } return normalized; } + +export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean { + const trimmed = (value ?? '').trim(); + if (!trimmed) return false; + return normalizeOpenClawAccountId(trimmed, '') === trimmed; +} diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx index 634b0f7ae..b3db69119 100644 --- a/src/components/channels/ChannelConfigModal.tsx +++ b/src/components/channels/ChannelConfigModal.tsx @@ -32,7 +32,11 @@ import { type ChannelMeta, type ChannelConfigField, } from '@/types/channel'; -import { buildQrChannelEventName, usesPluginManagedQrAccounts } from '@/lib/channel-alias'; +import { + buildQrChannelEventName, + isCanonicalOpenClawAccountId, + usesPluginManagedQrAccounts, +} from '@/lib/channel-alias'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import telegramIcon from '@/assets/channels/telegram.svg'; @@ -82,6 +86,7 @@ export function ChannelConfigModal({ const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); const [accountIdInput, setAccountIdInput] = useState(accountId || ''); + const [accountIdError, setAccountIdError] = useState(null); const [connecting, setConnecting] = useState(false); const [showSecrets, setShowSecrets] = useState>({}); const [qrCode, setQrCode] = useState(null); @@ -115,6 +120,7 @@ export function ChannelConfigModal({ useEffect(() => { setAccountIdInput(accountId || ''); + setAccountIdError(null); }, [accountId]); useEffect(() => { @@ -125,6 +131,7 @@ export function ChannelConfigModal({ setValidationResult(null); setQrCode(null); setConnecting(false); + setAccountIdError(null); return; } @@ -352,16 +359,28 @@ export function ChannelConfigModal({ if (showAccountIdEditor) { const nextAccountId = accountIdInput.trim(); if (!nextAccountId) { - toast.error(t('account.invalidId')); + const message = t('account.invalidId'); + setAccountIdError(message); + toast.error(message); + setConnecting(false); + return; + } + if (!isCanonicalOpenClawAccountId(nextAccountId)) { + const message = t('account.invalidCanonicalId'); + setAccountIdError(message); + toast.error(message); setConnecting(false); return; } const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim()); if (duplicateExists) { - toast.error(t('account.accountIdExists', { accountId: nextAccountId })); + const message = t('account.accountIdExists', { accountId: nextAccountId }); + setAccountIdError(message); + toast.error(message); setConnecting(false); return; } + setAccountIdError(null); } if (meta.connectionType === 'qr') { @@ -643,11 +662,20 @@ export function ChannelConfigModal({ setAccountIdInput(event.target.value)} + onChange={(event) => { + setAccountIdInput(event.target.value); + if (accountIdError) { + setAccountIdError(null); + } + }} placeholder={t('account.customIdPlaceholder')} - className={inputClasses} + className={cn(inputClasses, accountIdError && 'border-destructive/50 focus-visible:ring-destructive/30')} /> -

{t('account.customIdHint')}

+ {accountIdError ? ( +

{accountIdError}

+ ) : ( +

{t('account.customIdHint')}

+ )} )} diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index 1c59cc5e9..de1e5b235 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -47,8 +47,9 @@ "mainAccount": "Primary Account", "customIdLabel": "Account ID", "customIdPlaceholder": "e.g. feishu-sales-bot", - "customIdHint": "Use a custom account ID to distinguish multiple accounts under one channel.", + "customIdHint": "Use a lowercase account ID (letters, numbers, hyphen, underscore) to distinguish multiple accounts under one channel.", "invalidId": "Account ID cannot be empty", + "invalidCanonicalId": "Account ID must use lowercase letters, numbers, hyphens, or underscores, start with a letter/number, and be at most 64 characters.", "idLabel": "ID: {{id}}", "boundTo": "Bound to: {{agent}}", "handledBy": "Handled by {{agent}}", diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index d7e31f3a2..40396c801 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -47,8 +47,9 @@ "mainAccount": "メインアカウント", "customIdLabel": "アカウント ID", "customIdPlaceholder": "例: feishu-sales-bot", - "customIdHint": "同じチャンネル内の複数アカウントを区別するため、任意の ID を設定できます。", + "customIdHint": "同じチャンネル内の複数アカウントを区別するため、英小文字・数字・ハイフン・アンダースコアのみの ID を設定してください。", "invalidId": "アカウント ID は空にできません", + "invalidCanonicalId": "アカウント ID は英小文字・数字・ハイフン・アンダースコアのみ使用でき、先頭は英小文字または数字、最大 64 文字です。", "idLabel": "ID: {{id}}", "boundTo": "割り当て先: {{agent}}", "handledBy": "{{agent}} が処理", diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 78dfb031c..c39848545 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -47,8 +47,9 @@ "mainAccount": "主账号", "customIdLabel": "账号 ID", "customIdPlaceholder": "例如:feishu-sales-bot", - "customIdHint": "可自定义账号 ID,用于区分同一频道下的多个账号。", + "customIdHint": "使用小写账号 ID(字母、数字、连字符、下划线)来区分同一频道下的多个账号。", "invalidId": "账号 ID 不能为空", + "invalidCanonicalId": "账号 ID 仅支持小写字母、数字、连字符和下划线;必须以字母或数字开头,且最长 64 个字符。", "idLabel": "ID: {{id}}", "boundTo": "绑定对象:{{agent}}", "handledBy": "由 {{agent}} 处理", diff --git a/src/lib/channel-alias.ts b/src/lib/channel-alias.ts index 5b5a589d6..8af3d27a0 100644 --- a/src/lib/channel-alias.ts +++ b/src/lib/channel-alias.ts @@ -48,3 +48,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal } return normalized; } + +export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean { + const trimmed = (value ?? '').trim(); + if (!trimmed) return false; + return normalizeOpenClawAccountId(trimmed, '') === trimmed; +} diff --git a/tests/e2e/channels-account-id-validation.spec.ts b/tests/e2e/channels-account-id-validation.spec.ts new file mode 100644 index 000000000..e06ebc18e --- /dev/null +++ b/tests/e2e/channels-account-id-validation.spec.ts @@ -0,0 +1,94 @@ +import { completeSetup, expect, test } from './fixtures/electron'; + +const testConfigResponses = { + channelsAccounts: { + success: true, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'connected', + accounts: [ + { + accountId: 'default', + name: 'Primary Account', + configured: true, + status: 'connected', + isDefault: true, + }, + ], + }, + ], + }, + agents: { + success: true, + agents: [], + }, + credentialsValidate: { + success: true, + valid: true, + warnings: [], + }, + channelConfig: { + success: true, + }, +}; + +test.describe('Channels account ID validation', () => { + test('rejects non-canonical custom account ID before save', async ({ electronApp, page }) => { + await electronApp.evaluate(({ ipcMain }, responses) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__clawxE2eChannelConfigSaveCount = 0; + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { + const method = request?.method ?? 'GET'; + const path = request?.path ?? ''; + + if (path === '/api/channels/accounts' && method === 'GET') { + return { ok: true, data: { status: 200, ok: true, json: responses.channelsAccounts } }; + } + if (path === '/api/agents' && method === 'GET') { + return { ok: true, data: { status: 200, ok: true, json: responses.agents } }; + } + if (path === '/api/channels/credentials/validate' && method === 'POST') { + return { ok: true, data: { status: 200, ok: true, json: responses.credentialsValidate } }; + } + if (path === '/api/channels/config' && method === 'POST') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__clawxE2eChannelConfigSaveCount += 1; + return { ok: true, data: { status: 200, ok: true, json: responses.channelConfig } }; + } + if (path.startsWith('/api/channels/config/') && method === 'GET') { + return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } }; + } + return { + ok: false, + error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` }, + }; + }); + }, testConfigResponses); + + await completeSetup(page); + + await page.getByTestId('sidebar-nav-channels').click(); + await expect(page.getByTestId('channels-page')).toBeVisible(); + await expect(page.getByText('Feishu / Lark')).toBeVisible(); + + await page.getByRole('button', { name: /Add Account|account\.add/i }).click(); + await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible(); + + await page.locator('#account-id').fill('测试账号'); + await page.locator('#appId').fill('cli_test'); + await page.locator('#appSecret').fill('secret_test'); + + await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click(); + await expect(page.getByText(/account\.invalidCanonicalId|must use lowercase letters/i).first()).toBeVisible(); + + const saveCalls = await electronApp.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const count = Number((globalThis as any).__clawxE2eChannelConfigSaveCount || 0); + return { count }; + }); + expect(saveCalls.count).toBe(0); + }); +}); diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts index f377f5697..64e31d576 100644 --- a/tests/unit/channel-routes.test.ts +++ b/tests/unit/channel-routes.test.ts @@ -10,6 +10,11 @@ const readOpenClawConfigMock = vi.fn(); const listAgentsSnapshotMock = vi.fn(); const sendJsonMock = vi.fn(); const proxyAwareFetchMock = vi.fn(); +const saveChannelConfigMock = vi.fn(); +const setChannelDefaultAccountMock = vi.fn(); +const assignChannelAccountToAgentMock = vi.fn(); +const clearChannelBindingMock = vi.fn(); +const parseJsonBodyMock = vi.fn(); const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw'); vi.mock('@electron/utils/channel-config', () => ({ @@ -22,17 +27,17 @@ vi.mock('@electron/utils/channel-config', () => ({ listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args), listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args), readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), - saveChannelConfig: vi.fn(), - setChannelDefaultAccount: vi.fn(), + saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args), + setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args), setChannelEnabled: vi.fn(), validateChannelConfig: vi.fn(), validateChannelCredentials: vi.fn(), })); vi.mock('@electron/utils/agent-config', () => ({ - assignChannelAccountToAgent: vi.fn(), + assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args), clearAllBindingsForChannel: vi.fn(), - clearChannelBinding: vi.fn(), + clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args), listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args), })); @@ -59,7 +64,7 @@ vi.mock('@electron/utils/whatsapp-login', () => ({ })); vi.mock('@electron/api/route-utils', () => ({ - parseJsonBody: vi.fn().mockResolvedValue({}), + parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args), sendJson: (...args: unknown[]) => sendJsonMock(...args), })); @@ -93,6 +98,8 @@ describe('handleChannelRoutes', () => { vi.resetAllMocks(); rmSync(testOpenClawConfigDir, { recursive: true, force: true }); proxyAwareFetchMock.mockReset(); + parseJsonBodyMock.mockResolvedValue({}); + listConfiguredChannelAccountsMock.mockReturnValue({}); listAgentsSnapshotMock.mockResolvedValue({ agents: [], channelAccountOwners: {}, @@ -194,6 +201,214 @@ describe('handleChannelRoutes', () => { ); }); + it('rejects non-canonical account ID on channel config save', async () => { + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: '测试账号', + config: { appId: 'cli_xxx', appSecret: 'secret' }, + }); + + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + const handled = await handleChannelRoutes( + { method: 'POST' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/config'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + + expect(handled).toBe(true); + expect(sendJsonMock).toHaveBeenCalledWith( + expect.anything(), + 400, + expect.objectContaining({ + success: false, + error: expect.stringContaining('Invalid accountId format'), + }), + ); + expect(saveChannelConfigMock).not.toHaveBeenCalled(); + }); + + it('allows legacy non-canonical account ID on channel config save when account already exists', async () => { + parseJsonBodyMock.mockResolvedValue({ + channelType: 'telegram', + accountId: 'Legacy_Account', + config: { botToken: 'token', allowedUsers: '123456' }, + }); + listConfiguredChannelAccountsMock.mockReturnValue({ + telegram: { + defaultAccountId: 'default', + accountIds: ['default', 'Legacy_Account'], + }, + }); + + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + const handled = await handleChannelRoutes( + { method: 'POST' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/config'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + + expect(handled).toBe(true); + expect(saveChannelConfigMock).toHaveBeenCalledWith( + 'telegram', + { botToken: 'token', allowedUsers: '123456' }, + 'Legacy_Account', + ); + expect(sendJsonMock).toHaveBeenCalledWith( + expect.anything(), + 200, + expect.objectContaining({ success: true }), + ); + }); + + it('rejects non-canonical account ID on default-account route', async () => { + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: 'ABC', + }); + + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + await handleChannelRoutes( + { method: 'PUT' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/default-account'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + + expect(sendJsonMock).toHaveBeenCalledWith( + expect.anything(), + 400, + expect.objectContaining({ + success: false, + error: expect.stringContaining('Invalid accountId format'), + }), + ); + expect(setChannelDefaultAccountMock).not.toHaveBeenCalled(); + }); + + it('rejects non-canonical account ID on binding routes', async () => { + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: 'Account-Upper', + agentId: 'main', + }); + + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + await handleChannelRoutes( + { method: 'PUT' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/binding'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + + expect(sendJsonMock).toHaveBeenCalledWith( + expect.anything(), + 400, + expect.objectContaining({ + success: false, + error: expect.stringContaining('Invalid accountId format'), + }), + ); + expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled(); + + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: 'INVALID VALUE', + }); + await handleChannelRoutes( + { method: 'DELETE' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/binding'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + expect(clearChannelBindingMock).not.toHaveBeenCalled(); + }); + + it('allows legacy non-canonical account ID on default-account and binding routes', async () => { + listConfiguredChannelAccountsMock.mockReturnValue({ + feishu: { + defaultAccountId: 'default', + accountIds: ['default', 'Legacy_Account'], + }, + }); + + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: 'Legacy_Account', + }); + const { handleChannelRoutes } = await import('@electron/api/routes/channels'); + await handleChannelRoutes( + { method: 'PUT' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/default-account'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'Legacy_Account'); + + parseJsonBodyMock.mockResolvedValue({ + channelType: 'feishu', + accountId: 'Legacy_Account', + agentId: 'main', + }); + await handleChannelRoutes( + { method: 'PUT' } as IncomingMessage, + {} as ServerResponse, + new URL('http://127.0.0.1:13210/api/channels/binding'), + { + gatewayManager: { + rpc: vi.fn(), + getStatus: () => ({ state: 'running' }), + debouncedReload: vi.fn(), + debouncedRestart: vi.fn(), + }, + } as never, + ); + expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account'); + }); + it('keeps channel connected when one account is healthy and another errors', async () => { listConfiguredChannelsMock.mockResolvedValue(['telegram']); listConfiguredChannelAccountsMock.mockResolvedValue({ diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx index e8487a044..21449f794 100644 --- a/tests/unit/channels-page.test.tsx +++ b/tests/unit/channels-page.test.tsx @@ -4,6 +4,9 @@ import { Channels } from '@/pages/Channels/index'; const hostApiFetchMock = vi.fn(); const subscribeHostEventMock = vi.fn(); +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); +const toastWarningMock = vi.fn(); const { gatewayState } = vi.hoisted(() => ({ gatewayState: { @@ -31,9 +34,9 @@ vi.mock('react-i18next', () => ({ vi.mock('sonner', () => ({ toast: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + warning: (...args: unknown[]) => toastWarningMock(...args), }, })); @@ -83,6 +86,94 @@ describe('Channels page status refresh', () => { }); }); + it('blocks saving when custom account ID is non-canonical', async () => { + subscribeHostEventMock.mockImplementation(() => vi.fn()); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/channels/accounts') { + return { + success: true, + channels: [ + { + channelType: 'feishu', + defaultAccountId: 'default', + status: 'connected', + accounts: [ + { + accountId: 'default', + name: 'Primary Account', + configured: true, + status: 'connected', + isDefault: true, + }, + ], + }, + ], + }; + } + + if (path === '/api/agents') { + return { + success: true, + agents: [], + }; + } + + if (path === '/api/channels/credentials/validate') { + return { + success: true, + valid: true, + warnings: [], + }; + } + + if (path === '/api/channels/config') { + return { + success: true, + }; + } + + throw new Error(`Unexpected host API path: ${path}`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Feishu / Lark')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'account.add' })); + + await waitFor(() => { + expect(screen.getByText('dialog.configureTitle')).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByLabelText('account.customIdLabel'), { + target: { value: '测试账号' }, + }); + const appIdInput = document.getElementById('appId') as HTMLInputElement | null; + const appSecretInput = document.getElementById('appSecret') as HTMLInputElement | null; + expect(appIdInput).not.toBeNull(); + expect(appSecretInput).not.toBeNull(); + fireEvent.change(appIdInput!, { target: { value: 'cli_test' } }); + fireEvent.change(appSecretInput!, { target: { value: 'secret_test' } }); + + fireEvent.click(screen.getByRole('button', { name: 'dialog.saveAndConnect' })); + + await waitFor(() => { + expect(screen.getByText('account.invalidCanonicalId')).toBeInTheDocument(); + }); + expect(toastErrorMock).toHaveBeenCalledWith('account.invalidCanonicalId'); + + const saveCalls = hostApiFetchMock.mock.calls.filter(([path, init]) => ( + path === '/api/channels/config' + && typeof init === 'object' + && init != null + && 'method' in init + && (init as { method?: string }).method === 'POST' + )); + expect(saveCalls).toHaveLength(0); + }); + it('refetches channel accounts when gateway channel-status events arrive', async () => { let channelStatusHandler: (() => void) | undefined; subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {