Add unit tests for channel utilities and configure testing environment

- Created a new test file `channels.test.ts` to cover utilities related to channel configurations and targets.
- Implemented tests for normalizing and grouping selected channels by type, as well as building channel targets from account data and cron history.
- Mocked necessary dependencies to isolate tests and ensure accurate results.
- Updated `vite.config.ts` to set up the testing environment with jsdom and enable global variables for tests.
This commit is contained in:
duanshuwen
2026-04-18 16:12:49 +08:00
parent ee72cf7261
commit ef46c73c3e
26 changed files with 4056 additions and 186 deletions

376
src/lib/channel-meta.ts Normal file
View File

@@ -0,0 +1,376 @@
import type {
ChannelConfigFieldMeta,
ChannelConnectionType,
} from './channel-types';
export interface ChannelMeta {
type: string;
name: string;
description: string;
connectionType: ChannelConnectionType;
configFields: ChannelConfigFieldMeta[];
isPlugin: boolean;
instructions: string[];
}
function createField(
key: string,
label: string,
kind: ChannelConfigFieldMeta['kind'],
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return {
key,
label,
kind,
placeholder,
description,
required,
autoComplete: 'off',
};
}
function createTokenField(
key: string,
label: string,
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return createField(key, label, 'token', placeholder, description, required);
}
function createTextField(
key: string,
label: string,
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return createField(key, label, 'text', placeholder, description, required);
}
function createPasswordField(
key: string,
label: string,
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return createField(key, label, 'password', placeholder, description, required);
}
function createTextareaField(
key: string,
label: string,
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return {
...createField(key, label, 'textarea', placeholder, description, required),
rows: 5,
};
}
function createUrlField(
key: string,
label: string,
placeholder?: string,
description?: string,
required = false,
): ChannelConfigFieldMeta {
return createField(key, label, 'url', placeholder, description, required);
}
export const PRIMARY_CHANNEL_TYPES = [
'telegram',
'discord',
'whatsapp',
'wechat',
'dingtalk',
'feishu',
'wecom',
'qqbot',
] as const;
export const CHANNEL_META_LIST: ChannelMeta[] = [
{
type: 'telegram',
name: 'Telegram',
description: '使用 @BotFather 提供的机器人令牌连接 Telegram。',
connectionType: 'token',
configFields: [
createPasswordField('botToken', 'Bot Token', '123456:telegram-bot-token', '从 @BotFather 获取的机器人令牌。', true),
createTextField('allowedUsers', 'Allowed Users', '12345678,98765432', '可选。限制允许与机器人对话的 Telegram 用户 ID。'),
],
isPlugin: false,
instructions: [
'在 Telegram 中使用 @BotFather 创建机器人并复制 Bot Token。',
'如果需要限制测试范围,可填写允许访问的用户 ID 列表。',
'保存后在消息频道页确认默认账号、归属 Agent 和运行态状态。',
],
},
{
type: 'discord',
name: 'Discord',
description: '使用开发者门户提供的机器人令牌连接 Discord。',
connectionType: 'token',
configFields: [
createPasswordField('token', 'Bot Token', 'discord-bot-token', 'Discord Bot Token。', true),
createTextField('guildId', 'Guild ID', '123456789012345678', '推荐填写,便于定位绑定的服务器。', true),
createTextField('channelId', 'Channel ID', '123456789012345678', '可选。指定默认投递频道。'),
],
isPlugin: false,
instructions: [
'在 Discord Developer Portal 创建应用并启用 Bot。',
'复制 Token并在目标服务器中邀请该机器人。',
'如果需要固定默认频道,可补充填写 Guild ID / Channel ID。',
],
},
{
type: 'whatsapp',
name: 'WhatsApp',
description: '通过扫描二维码连接 WhatsApp无需手机号。',
connectionType: 'qr',
configFields: [],
isPlugin: false,
instructions: [
'保存后等待 runtime 侧二维码能力接入。',
'当前 `zn-ai` 已预留配置和状态位,二维码拉起仍是后续波次。',
'在完全对齐 ClawX 前,建议先使用默认账号完成路由与绑定链路验证。',
],
},
{
type: 'wechat',
name: 'WeChat',
description: '通过腾讯官方 OpenClaw 插件扫码连接个人微信。',
connectionType: 'qr',
configFields: [],
isPlugin: true,
instructions: [
'该频道依赖插件与二维码链路,当前页面已保留入口。',
'后续需要补齐扫码事件、成功回调和账号自动发现。',
'Agent 绑定、默认账号和删除流程已可先行对齐。',
],
},
{
type: 'dingtalk',
name: 'DingTalk',
description: '通过 OpenClaw 渠道插件连接钉钉Stream 模式)。',
connectionType: 'token',
configFields: [
createTextField('clientId', 'Client ID', 'ding-app-key', '钉钉应用的 AppKey。', true),
createPasswordField('clientSecret', 'Client Secret', 'ding-app-secret', '钉钉应用的 AppSecret。', true),
createTextField('robotCode', 'Robot Code', 'dingxxxx', '可选。机器人编码。'),
createTextField('corpId', 'Corp ID', 'dingcorp123', '可选。企业 corpId。'),
createTextField('agentId', 'Agent ID', '123456789', '可选。机器人 AgentId。'),
],
isPlugin: true,
instructions: [
'在钉钉开发者后台创建机器人并获取 AppKey / AppSecret。',
'按需补充 Robot Code、Corp ID 或 Agent ID 以兼容不同部署方式。',
'保存后优先验证默认账号、频道归属和 gateway 重载是否收敛。',
],
},
{
type: 'feishu',
name: 'Feishu / Lark',
description: '通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人。',
connectionType: 'token',
configFields: [
createTextField('appId', 'App ID', 'cli_xxxxxxxxx', '飞书应用的 App ID。', true),
createPasswordField('appSecret', 'App Secret', 'app-secret', '飞书应用的 App Secret。', true),
],
isPlugin: true,
instructions: [
'在飞书开放平台创建机器人应用并开启事件订阅。',
'填入 App ID 与 App Secret保存后完成默认账号与 Agent 绑定。',
'这是 ClawX 中验证最完整的频道之一,建议作为首个联调样板。',
],
},
{
type: 'wecom',
name: 'WeCom',
description: '通过插件连接企业微信机器人。',
connectionType: 'token',
configFields: [
createTextField('botId', 'Bot ID', 'wecom-bot-id', '企业微信机器人或应用标识。', true),
createPasswordField('secret', 'Secret', 'wecom-secret', '企业微信机器人密钥。', true),
],
isPlugin: true,
instructions: [
'在企业微信管理后台创建机器人并复制关键信息。',
'建议先用默认账号联通消息,再扩展多账号分工。',
'保存后使用消息频道页统一管理默认账号和 Agent 归属。',
],
},
{
type: 'qqbot',
name: 'QQ Bot',
description: '连接 QQ 机器人频道OpenClaw 3.31 起内置)。',
connectionType: 'token',
configFields: [
createTextField('appId', 'App ID', 'qq-app-id', 'QQ Bot App ID。', true),
createPasswordField('clientSecret', 'Client Secret', 'qq-client-secret', 'QQ Bot Client Secret。', true),
],
isPlugin: false,
instructions: [
'在 QQ Bot 平台创建机器人并获取 App ID 与密钥。',
'如需多账号场景,新增账号时建议使用清晰的 accountId 规则。',
'对齐完成后Cron、目标选择和路由会直接复用这里的配置。',
],
},
{
type: 'signal',
name: 'Signal',
description: '通过 Signal 服务号码接入消息路由。',
connectionType: 'token',
configFields: [
createTextField('phoneNumber', 'Phone Number', '+8613800138000', 'Signal 注册号码。', true),
],
isPlugin: false,
instructions: [
'准备已注册 Signal 的号码。',
'补充 runtime 侧连接能力后即可复用当前配置模型。',
],
},
{
type: 'imessage',
name: 'iMessage',
description: '通过桥接服务接入 iMessage。',
connectionType: 'token',
configFields: [
createUrlField('serverUrl', 'Server URL', 'https://imessage-bridge.example.com', '桥接服务地址。', true),
createPasswordField('password', 'Password', 'bridge-password', '桥接服务认证密码。', true),
],
isPlugin: false,
instructions: [
'先部署 iMessage bridge 服务。',
'保存后验证服务地址和认证信息是否可用。',
],
},
{
type: 'matrix',
name: 'Matrix',
description: '通过 homeserver 和 access token 接入 Matrix。',
connectionType: 'token',
configFields: [
createUrlField('homeserver', 'Homeserver', 'https://matrix.example.com', 'Matrix homeserver 地址。', true),
createPasswordField('accessToken', 'Access Token', 'matrix-access-token', 'Matrix access token。', true),
],
isPlugin: true,
instructions: [
'准备 Matrix homeserver 与 access token。',
'建议后续将 channel target 自动发现扩展到 roomId 级别。',
],
},
{
type: 'line',
name: 'LINE',
description: '通过 LINE Messaging API 接入机器人。',
connectionType: 'token',
configFields: [
createPasswordField('channelAccessToken', 'Channel Access Token', 'line-channel-access-token', 'LINE Channel Access Token。', true),
createPasswordField('channelSecret', 'Channel Secret', 'line-channel-secret', 'LINE Channel Secret。', true),
],
isPlugin: true,
instructions: [
'在 LINE Developers 中创建 Messaging API channel。',
'保存后验证 token、secret 与默认投递账号。',
],
},
{
type: 'msteams',
name: 'Microsoft Teams',
description: '通过 Bot Framework 凭证接入 Microsoft Teams。',
connectionType: 'token',
configFields: [
createTextField('appId', 'App ID', 'teams-app-id', 'Bot Framework App ID。', true),
createPasswordField('appPassword', 'App Password', 'teams-app-password', 'Bot Framework App Password。', true),
],
isPlugin: true,
instructions: [
'在 Azure / Bot Framework 中创建 Teams Bot。',
'保存后与默认账号、Agent 绑定一并验证。',
],
},
{
type: 'googlechat',
name: 'Google Chat',
description: '通过 Google Chat 服务账号接入消息投递。',
connectionType: 'webhook',
configFields: [
createTextareaField('serviceAccountKey', 'Service Account Key', '{ ...json... }', 'Google 服务账号 JSON 内容。', true),
],
isPlugin: false,
instructions: [
'在 Google Cloud 中创建服务账号并开启 Chat API。',
'将服务账号 JSON 粘贴到配置中,后续可扩展 webhook / space 目标发现。',
],
},
{
type: 'mattermost',
name: 'Mattermost',
description: '通过服务地址与 Bot Token 接入 Mattermost。',
connectionType: 'token',
configFields: [
createUrlField('serverUrl', 'Server URL', 'https://mattermost.example.com', 'Mattermost 服务地址。', true),
createPasswordField('botToken', 'Bot Token', 'mattermost-bot-token', 'Mattermost Bot Token。', true),
],
isPlugin: true,
instructions: [
'在 Mattermost 中创建 Bot 并生成 Token。',
'后续建议把 channel target 扩展到 team/channel 自动发现。',
],
},
];
export const CHANNEL_META_MAP = CHANNEL_META_LIST.reduce<Record<string, ChannelMeta>>((accumulator, meta) => {
accumulator[meta.type] = meta;
return accumulator;
}, {});
export const DEFAULT_CHANNEL_META: ChannelMeta = {
type: 'custom',
name: 'Custom Channel',
description: '通用频道模板,用于承接尚未内建 schema 的自定义渠道或插件。',
connectionType: 'plugin',
configFields: [
createTokenField('token', 'Token', 'token-or-secret', '粘贴该渠道要求的 token、secret 或访问凭据。'),
],
isPlugin: true,
instructions: [
'当渠道 schema 仍在演进,或由外部插件提供时,可先使用该模板。',
'保存后仍可通过消息频道页管理默认账号、Agent 绑定与运行态状态。',
],
};
export function getChannelMeta(channelType: string | null | undefined): ChannelMeta {
const trimmed = String(channelType ?? '').trim();
if (!trimmed) {
return DEFAULT_CHANNEL_META;
}
return CHANNEL_META_MAP[trimmed] ?? {
...DEFAULT_CHANNEL_META,
type: trimmed,
name: trimmed,
description: `${trimmed} 的通用频道模板。`,
instructions: [
...DEFAULT_CHANNEL_META.instructions,
`当前 runtime 尚未为 ${trimmed} 提供专用 schema先以数据驱动方式接入。`,
],
};
}
export function getChannelOptions(): ChannelMeta[] {
return [...CHANNEL_META_LIST, DEFAULT_CHANNEL_META];
}
export function getPrimaryChannelOptions(): ChannelMeta[] {
const primarySet = new Set<string>(PRIMARY_CHANNEL_TYPES);
return CHANNEL_META_LIST.filter((item) => primarySet.has(item.type));
}

View File

@@ -1,9 +1,34 @@
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export type ChannelConnectionStatus =
| 'connected'
| 'connecting'
| 'disconnected'
| 'error'
| 'degraded';
export type ChannelConnectionType = 'token' | 'account' | 'oauth' | 'plugin' | 'qr' | 'webhook';
export type ChannelConfigFieldKind = 'text' | 'password' | 'textarea' | 'token' | 'url';
export interface ChannelConfigFieldMeta {
key: string;
label: string;
kind: ChannelConfigFieldKind;
placeholder?: string;
description?: string;
required?: boolean;
rows?: number;
autoComplete?: string;
}
export interface ChannelConfigFieldValueMap {
[key: string]: string;
}
export interface ChannelAccountCatalogItem {
accountId: string;
name: string;
configured: boolean;
enabled?: boolean;
status: ChannelConnectionStatus;
lastError?: string;
isDefault: boolean;
@@ -16,6 +41,7 @@ export interface ChannelAccountCatalogGroup {
channelType: string;
channelLabel: string;
defaultAccountId: string;
enabled?: boolean;
status: ChannelConnectionStatus;
accounts: ChannelAccountCatalogItem[];
}