fix(feishu): feishu connector name validate (#797)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-04-08 19:16:15 +08:00
committed by GitHub
Unverified
parent c1e165d48d
commit d03902dd4d
13 changed files with 521 additions and 17 deletions

View File

@@ -105,6 +105,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
### 📡 マルチチャネル管理 ### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。 現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
カスタムのチャンネルアカウント ID には、ルーティング不一致を防ぐため OpenClaw 互換の正規形式(`[a-z0-9_-]`、英小文字、最大 64 文字、先頭は英小文字または数字)を必須にしています。
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。 ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
### ⏰ Cronベースの自動化 ### ⏰ Cronベースの自動化

View File

@@ -105,6 +105,7 @@ Each agent can also override its own `provider/model` runtime setting; agents wi
### 📡 Multi-Channel Management ### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. 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. 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. 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 ### ⏰ Cron-Based Automation

View File

@@ -106,6 +106,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
### 📡 多频道管理 ### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。 现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
对于自定义频道账号 IDClawX 现在会强制校验 OpenClaw 兼容的规范格式(`[a-z0-9_-]`、小写、最长 64 位、且必须以字母或数字开头),避免路由匹配异常。
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。 ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
### ⏰ 定时任务自动化 ### ⏰ 定时任务自动化

View File

@@ -38,6 +38,7 @@ import {
OPENCLAW_WECHAT_CHANNEL_TYPE, OPENCLAW_WECHAT_CHANNEL_TYPE,
UI_WECHAT_CHANNEL_TYPE, UI_WECHAT_CHANNEL_TYPE,
buildQrChannelEventName, buildQrChannelEventName,
isCanonicalOpenClawAccountId,
toOpenClawChannelType, toOpenClawChannelType,
toUiChannelType, toUiChannelType,
} from '../../utils/channel-alias'; } from '../../utils/channel-alias';
@@ -89,6 +90,47 @@ function buildQrLoginKey(channelType: string, accountId?: string): string {
return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`; return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`;
} }
async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise<boolean> {
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<string | null> {
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<boolean> {
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 { function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string {
const loginKey = buildQrLoginKey(channelType, accountId); const loginKey = buildQrLoginKey(channelType, accountId);
activeQrLogins.set(loginKey, sessionKey); activeQrLogins.set(loginKey, sessionKey);
@@ -1087,6 +1129,10 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') { if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
try { try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); 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); await setChannelDefaultAccount(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`);
sendJson(res, 200, { success: true }); sendJson(res, 200, { success: true });
@@ -1099,6 +1145,10 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') { if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
try { try {
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req); 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); await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
sendJson(res, 200, { success: true }); sendJson(res, 200, { success: true });
@@ -1111,6 +1161,10 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') { if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
try { try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req); 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); await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
sendJson(res, 200, { success: true }); sendJson(res, 200, { success: true });
@@ -1212,6 +1266,10 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/config' && req.method === 'POST') { if (url.pathname === '/api/channels/config' && req.method === 'POST') {
try { try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req); const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
if (!validAccountId) {
return true;
}
const storedChannelType = resolveStoredChannelType(body.channelType); const storedChannelType = resolveStoredChannelType(body.channelType);
if (storedChannelType === 'dingtalk') { if (storedChannelType === 'dingtalk') {
const installResult = await ensureDingTalkPluginInstalled(); const installResult = await ensureDingTalkPluginInstalled();

View File

@@ -44,3 +44,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal
} }
return normalized; return normalized;
} }
export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean {
const trimmed = (value ?? '').trim();
if (!trimmed) return false;
return normalizeOpenClawAccountId(trimmed, '') === trimmed;
}

View File

@@ -32,7 +32,11 @@ import {
type ChannelMeta, type ChannelMeta,
type ChannelConfigField, type ChannelConfigField,
} from '@/types/channel'; } from '@/types/channel';
import { buildQrChannelEventName, usesPluginManagedQrAccounts } from '@/lib/channel-alias'; import {
buildQrChannelEventName,
isCanonicalOpenClawAccountId,
usesPluginManagedQrAccounts,
} from '@/lib/channel-alias';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import telegramIcon from '@/assets/channels/telegram.svg'; import telegramIcon from '@/assets/channels/telegram.svg';
@@ -82,6 +86,7 @@ export function ChannelConfigModal({
const [configValues, setConfigValues] = useState<Record<string, string>>({}); const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState(''); const [channelName, setChannelName] = useState('');
const [accountIdInput, setAccountIdInput] = useState(accountId || ''); const [accountIdInput, setAccountIdInput] = useState(accountId || '');
const [accountIdError, setAccountIdError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({}); const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const [qrCode, setQrCode] = useState<string | null>(null); const [qrCode, setQrCode] = useState<string | null>(null);
@@ -115,6 +120,7 @@ export function ChannelConfigModal({
useEffect(() => { useEffect(() => {
setAccountIdInput(accountId || ''); setAccountIdInput(accountId || '');
setAccountIdError(null);
}, [accountId]); }, [accountId]);
useEffect(() => { useEffect(() => {
@@ -125,6 +131,7 @@ export function ChannelConfigModal({
setValidationResult(null); setValidationResult(null);
setQrCode(null); setQrCode(null);
setConnecting(false); setConnecting(false);
setAccountIdError(null);
return; return;
} }
@@ -352,16 +359,28 @@ export function ChannelConfigModal({
if (showAccountIdEditor) { if (showAccountIdEditor) {
const nextAccountId = accountIdInput.trim(); const nextAccountId = accountIdInput.trim();
if (!nextAccountId) { 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); setConnecting(false);
return; return;
} }
const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim()); const duplicateExists = existingAccountIds.some((id) => id === nextAccountId && id !== (accountId || '').trim());
if (duplicateExists) { if (duplicateExists) {
toast.error(t('account.accountIdExists', { accountId: nextAccountId })); const message = t('account.accountIdExists', { accountId: nextAccountId });
setAccountIdError(message);
toast.error(message);
setConnecting(false); setConnecting(false);
return; return;
} }
setAccountIdError(null);
} }
if (meta.connectionType === 'qr') { if (meta.connectionType === 'qr') {
@@ -643,11 +662,20 @@ export function ChannelConfigModal({
<Input <Input
id="account-id" id="account-id"
value={accountIdInput} value={accountIdInput}
onChange={(event) => setAccountIdInput(event.target.value)} onChange={(event) => {
setAccountIdInput(event.target.value);
if (accountIdError) {
setAccountIdError(null);
}
}}
placeholder={t('account.customIdPlaceholder')} placeholder={t('account.customIdPlaceholder')}
className={inputClasses} className={cn(inputClasses, accountIdError && 'border-destructive/50 focus-visible:ring-destructive/30')}
/> />
<p className="text-[12px] text-muted-foreground">{t('account.customIdHint')}</p> {accountIdError ? (
<p className="text-[12px] text-destructive">{accountIdError}</p>
) : (
<p className="text-[12px] text-muted-foreground">{t('account.customIdHint')}</p>
)}
</div> </div>
)} )}

View File

@@ -47,8 +47,9 @@
"mainAccount": "Primary Account", "mainAccount": "Primary Account",
"customIdLabel": "Account ID", "customIdLabel": "Account ID",
"customIdPlaceholder": "e.g. feishu-sales-bot", "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", "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}}", "idLabel": "ID: {{id}}",
"boundTo": "Bound to: {{agent}}", "boundTo": "Bound to: {{agent}}",
"handledBy": "Handled by {{agent}}", "handledBy": "Handled by {{agent}}",

View File

@@ -47,8 +47,9 @@
"mainAccount": "メインアカウント", "mainAccount": "メインアカウント",
"customIdLabel": "アカウント ID", "customIdLabel": "アカウント ID",
"customIdPlaceholder": "例: feishu-sales-bot", "customIdPlaceholder": "例: feishu-sales-bot",
"customIdHint": "同じチャンネル内の複数アカウントを区別するため、任意の ID を設定できます。", "customIdHint": "同じチャンネル内の複数アカウントを区別するため、英小文字・数字・ハイフン・アンダースコアのみの ID を設定してください。",
"invalidId": "アカウント ID は空にできません", "invalidId": "アカウント ID は空にできません",
"invalidCanonicalId": "アカウント ID は英小文字・数字・ハイフン・アンダースコアのみ使用でき、先頭は英小文字または数字、最大 64 文字です。",
"idLabel": "ID: {{id}}", "idLabel": "ID: {{id}}",
"boundTo": "割り当て先: {{agent}}", "boundTo": "割り当て先: {{agent}}",
"handledBy": "{{agent}} が処理", "handledBy": "{{agent}} が処理",

View File

@@ -47,8 +47,9 @@
"mainAccount": "主账号", "mainAccount": "主账号",
"customIdLabel": "账号 ID", "customIdLabel": "账号 ID",
"customIdPlaceholder": "例如feishu-sales-bot", "customIdPlaceholder": "例如feishu-sales-bot",
"customIdHint": "可自定义账号 ID,用于区分同一频道下的多个账号。", "customIdHint": "使用小写账号 ID(字母、数字、连字符、下划线)来区分同一频道下的多个账号。",
"invalidId": "账号 ID 不能为空", "invalidId": "账号 ID 不能为空",
"invalidCanonicalId": "账号 ID 仅支持小写字母、数字、连字符和下划线;必须以字母或数字开头,且最长 64 个字符。",
"idLabel": "ID: {{id}}", "idLabel": "ID: {{id}}",
"boundTo": "绑定对象:{{agent}}", "boundTo": "绑定对象:{{agent}}",
"handledBy": "由 {{agent}} 处理", "handledBy": "由 {{agent}} 处理",

View File

@@ -48,3 +48,9 @@ export function normalizeOpenClawAccountId(value: string | null | undefined, fal
} }
return normalized; return normalized;
} }
export function isCanonicalOpenClawAccountId(value: string | null | undefined): boolean {
const trimmed = (value ?? '').trim();
if (!trimmed) return false;
return normalizeOpenClawAccountId(trimmed, '') === trimmed;
}

View File

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

View File

@@ -10,6 +10,11 @@ const readOpenClawConfigMock = vi.fn();
const listAgentsSnapshotMock = vi.fn(); const listAgentsSnapshotMock = vi.fn();
const sendJsonMock = vi.fn(); const sendJsonMock = vi.fn();
const proxyAwareFetchMock = 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'); const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');
vi.mock('@electron/utils/channel-config', () => ({ vi.mock('@electron/utils/channel-config', () => ({
@@ -22,17 +27,17 @@ vi.mock('@electron/utils/channel-config', () => ({
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args), listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args), listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args),
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args), readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
saveChannelConfig: vi.fn(), saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args),
setChannelDefaultAccount: vi.fn(), setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args),
setChannelEnabled: vi.fn(), setChannelEnabled: vi.fn(),
validateChannelConfig: vi.fn(), validateChannelConfig: vi.fn(),
validateChannelCredentials: vi.fn(), validateChannelCredentials: vi.fn(),
})); }));
vi.mock('@electron/utils/agent-config', () => ({ vi.mock('@electron/utils/agent-config', () => ({
assignChannelAccountToAgent: vi.fn(), assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args),
clearAllBindingsForChannel: vi.fn(), clearAllBindingsForChannel: vi.fn(),
clearChannelBinding: vi.fn(), clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args),
listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args), listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
listAgentsSnapshotFromConfig: (...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', () => ({ vi.mock('@electron/api/route-utils', () => ({
parseJsonBody: vi.fn().mockResolvedValue({}), parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
sendJson: (...args: unknown[]) => sendJsonMock(...args), sendJson: (...args: unknown[]) => sendJsonMock(...args),
})); }));
@@ -93,6 +98,8 @@ describe('handleChannelRoutes', () => {
vi.resetAllMocks(); vi.resetAllMocks();
rmSync(testOpenClawConfigDir, { recursive: true, force: true }); rmSync(testOpenClawConfigDir, { recursive: true, force: true });
proxyAwareFetchMock.mockReset(); proxyAwareFetchMock.mockReset();
parseJsonBodyMock.mockResolvedValue({});
listConfiguredChannelAccountsMock.mockReturnValue({});
listAgentsSnapshotMock.mockResolvedValue({ listAgentsSnapshotMock.mockResolvedValue({
agents: [], agents: [],
channelAccountOwners: {}, 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 () => { it('keeps channel connected when one account is healthy and another errors', async () => {
listConfiguredChannelsMock.mockResolvedValue(['telegram']); listConfiguredChannelsMock.mockResolvedValue(['telegram']);
listConfiguredChannelAccountsMock.mockResolvedValue({ listConfiguredChannelAccountsMock.mockResolvedValue({

View File

@@ -4,6 +4,9 @@ import { Channels } from '@/pages/Channels/index';
const hostApiFetchMock = vi.fn(); const hostApiFetchMock = vi.fn();
const subscribeHostEventMock = vi.fn(); const subscribeHostEventMock = vi.fn();
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
const toastWarningMock = vi.fn();
const { gatewayState } = vi.hoisted(() => ({ const { gatewayState } = vi.hoisted(() => ({
gatewayState: { gatewayState: {
@@ -31,9 +34,9 @@ vi.mock('react-i18next', () => ({
vi.mock('sonner', () => ({ vi.mock('sonner', () => ({
toast: { toast: {
success: vi.fn(), success: (...args: unknown[]) => toastSuccessMock(...args),
error: vi.fn(), error: (...args: unknown[]) => toastErrorMock(...args),
warning: vi.fn(), 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(<Channels />);
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 () => { it('refetches channel accounts when gateway channel-status events arrive', async () => {
let channelStatusHandler: (() => void) | undefined; let channelStatusHandler: (() => void) | undefined;
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => { subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {