Files
zn-ai/electron/utils/channels.ts
duanshuwen ef46c73c3e 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.
2026-04-18 16:12:49 +08:00

532 lines
18 KiB
TypeScript

import { CONFIG_KEYS } from '@runtime/lib/constants';
import { normalizeAgentId, type AgentsSnapshot } from '@runtime/lib/models';
import configManager from '@electron/service/config-service';
import { listCronJobs } from './cron-store';
import { listStoredChannelAccountRecords } from './channel-config';
import {
buildChannelStatusSummary,
inferChannelConnectionStatus,
} from './channel-status';
import type {
ChannelAccountCatalogGroup,
ChannelConnectionStatus,
ChannelTargetCatalogItem,
ChannelTargetKind,
ChannelTargetSource,
} from '@src/lib/channel-types';
interface SelectedChannelConfigItem {
id: string;
channelName: string;
channelUrl: string;
}
export interface LocalChannelAccount {
id: string;
accountId: string;
channelType: string;
channelName: string;
channelUrl: string;
label: string;
configured: boolean;
enabled: boolean;
channelEnabled: boolean;
status: ChannelConnectionStatus;
isDefault: boolean;
lastError?: string;
ownerAgentId: string | null;
ownerAgentName: string | null;
bindingScope: 'account' | 'channel' | null;
}
const CHANNEL_TYPE_ALIASES: Record<string, string> = {
douyin: 'douyin',
'抖音': 'douyin',
fliggy: 'fliggy',
'飞猪': 'fliggy',
meituan: 'meituan',
'美团': 'meituan',
};
const CHANNEL_HOST_TYPE_ALIASES: Record<string, string> = {
'life.douyin.com': 'douyin',
'douyin.com': 'douyin',
'hotel.fliggy.com': 'fliggy',
'fliggy.com': 'fliggy',
'me.meituan.com': 'meituan',
'meituan.com': 'meituan',
};
const TARGET_QUERY_PARAM_LABELS: Record<string, string> = {
accountid: 'Account ID',
chatid: 'Chat ID',
conversationid: 'Conversation ID',
groupid: 'Group ID',
hotelid: 'Hotel ID',
merchantid: 'Merchant ID',
openconversationid: 'Open Conversation ID',
roomid: 'Room ID',
sellerid: 'Seller ID',
shopid: 'Shop ID',
threadid: 'Thread ID',
userid: 'User ID',
};
function normalizeTargetValue(value: string | null | undefined): string {
return String(value ?? '').trim();
}
function buildTargetCandidate(
value: string | null | undefined,
label: string,
options: {
description?: string;
kind?: ChannelTargetKind;
source?: ChannelTargetSource;
channelType?: string;
accountId?: string;
} = {},
): ChannelTargetCatalogItem | null {
const normalizedValue = normalizeTargetValue(value);
if (!normalizedValue) return null;
return {
value: normalizedValue,
label: label.trim() || normalizedValue,
description: options.description?.trim() || undefined,
kind: options.kind,
source: options.source,
channelType: options.channelType,
accountId: options.accountId,
};
}
function pushTargetCandidate(
targetMap: Map<string, ChannelTargetCatalogItem>,
candidate: ChannelTargetCatalogItem | null,
): void {
if (!candidate) return;
const existing = targetMap.get(candidate.value);
if (!existing) {
targetMap.set(candidate.value, candidate);
return;
}
targetMap.set(candidate.value, {
...existing,
label: existing.label || candidate.label,
description: existing.description || candidate.description,
kind: existing.kind || candidate.kind,
source: existing.source || candidate.source,
channelType: existing.channelType || candidate.channelType,
accountId: existing.accountId || candidate.accountId,
});
}
function appendUrlTargetCandidates(
account: Pick<LocalChannelAccount, 'channelType' | 'accountId' | 'channelName' | 'channelUrl'>,
targetMap: Map<string, ChannelTargetCatalogItem>,
): void {
const rawUrl = String(account.channelUrl ?? '').trim();
if (!rawUrl) return;
try {
const parsedUrl = new URL(rawUrl);
const queryEntries = [
...parsedUrl.searchParams.entries(),
...new URLSearchParams(parsedUrl.hash.split('?')[1] ?? '').entries(),
];
for (const [rawKey, rawValue] of queryEntries) {
const key = rawKey.trim().toLowerCase();
const value = rawValue.trim();
if (!value) continue;
const labelBase = TARGET_QUERY_PARAM_LABELS[key]
|| (/id$/.test(key) ? key.replace(/id$/, ' ID') : '');
if (!labelBase) continue;
pushTargetCandidate(
targetMap,
buildTargetCandidate(value, `${labelBase} · ${value}`, {
description: `${account.channelName || account.accountId} URL 中发现的候选标识`,
kind: 'identifier',
source: parsedUrl.searchParams.has(rawKey) ? 'query-param' : 'hash-param',
channelType: account.channelType,
accountId: account.accountId,
}),
);
}
if (/webhook|hook|bot|robot/i.test(parsedUrl.toString())) {
pushTargetCandidate(
targetMap,
buildTargetCandidate(parsedUrl.toString(), `Webhook · ${account.channelName || account.accountId}`, {
description: '渠道 URL 看起来像一个可直接发送的 webhook 目标',
kind: 'webhook',
source: 'channel-url',
channelType: account.channelType,
accountId: account.accountId,
}),
);
}
} catch {
// Ignore malformed URLs and keep the other fallback targets.
}
}
function appendHistoricalTargetCandidates(
channelType: string,
accountId: string | null | undefined,
targetMap: Map<string, ChannelTargetCatalogItem>,
): void {
const normalizedChannelType = String(channelType ?? '').trim();
const normalizedAccountId = String(accountId ?? '').trim();
for (const job of listCronJobs()) {
const delivery = job.delivery;
if (!delivery || delivery.mode !== 'announce') continue;
if (String(delivery.channel ?? '').trim() !== normalizedChannelType) continue;
const deliveryAccountId = String(delivery.accountId ?? '').trim();
if (normalizedAccountId && deliveryAccountId && deliveryAccountId !== normalizedAccountId) {
continue;
}
const targetValue = String(delivery.to ?? '').trim();
if (!targetValue) continue;
pushTargetCandidate(
targetMap,
buildTargetCandidate(targetValue, targetValue, {
description: `来自历史定时任务「${job.name || job.id}」的投递目标`,
kind: /^https?:\/\//i.test(targetValue) ? 'webhook' : 'name',
source: 'fallback',
channelType: normalizedChannelType,
accountId: deliveryAccountId || normalizedAccountId || undefined,
}),
);
}
}
function formatChannelLabel(channelType: string): string {
const parts = String(channelType ?? '')
.split(/[-_]/)
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) {
return channelType;
}
return parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function normalizeSelectedChannel(item: Partial<SelectedChannelConfigItem> | null | undefined): SelectedChannelConfigItem | null {
const channelUrl = String(item?.channelUrl ?? '').trim();
if (!channelUrl) return null;
const channelName = String(item?.channelName ?? channelUrl).trim() || channelUrl;
const id = String(item?.id ?? channelUrl).trim() || channelUrl;
return {
id,
channelName,
channelUrl,
};
}
function slugifyChannelType(value: string): string {
const normalized = value
.normalize('NFKD')
.replace(/[^\w\s-]/g, '')
.toLowerCase()
.replace(/[_\s]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return normalized || 'channel';
}
function inferChannelType(item: SelectedChannelConfigItem): string {
const nameKey = item.channelName.trim();
if (CHANNEL_TYPE_ALIASES[nameKey]) return CHANNEL_TYPE_ALIASES[nameKey];
const lowerNameKey = nameKey.toLowerCase();
if (CHANNEL_TYPE_ALIASES[lowerNameKey]) return CHANNEL_TYPE_ALIASES[lowerNameKey];
try {
const hostname = new URL(item.channelUrl).hostname.toLowerCase();
if (CHANNEL_HOST_TYPE_ALIASES[hostname]) return CHANNEL_HOST_TYPE_ALIASES[hostname];
const matchedHost = Object.entries(CHANNEL_HOST_TYPE_ALIASES)
.find(([host]) => hostname === host || hostname.endsWith(`.${host}`));
if (matchedHost) return matchedHost[1];
} catch {
// Fall through to a slugified channel name.
}
return slugifyChannelType(lowerNameKey || item.id || item.channelUrl);
}
export function getSelectedChannelsConfig(): SelectedChannelConfigItem[] {
const saved = configManager.get<SelectedChannelConfigItem[]>(CONFIG_KEYS.SELECTED_CHANNELS);
if (!Array.isArray(saved)) return [];
const deduped = new Map<string, SelectedChannelConfigItem>();
for (const item of saved) {
const normalized = normalizeSelectedChannel(item);
if (!normalized) continue;
const dedupeKey = `${inferChannelType(normalized)}:${normalized.id}`;
if (!deduped.has(dedupeKey)) {
deduped.set(dedupeKey, normalized);
}
}
return Array.from(deduped.values());
}
export function listSelectedChannelAccounts(snapshot?: Pick<AgentsSnapshot, 'agents' | 'channelOwners' | 'channelAccountOwners'>): LocalChannelAccount[] {
const agentNameById = new Map<string, string>(
Array.isArray(snapshot?.agents)
? snapshot.agents.map((agent) => [normalizeAgentId(agent.id), agent.name || normalizeAgentId(agent.id)])
: [],
);
const accounts = new Map<string, LocalChannelAccount>();
for (const record of listStoredChannelAccountRecords()) {
const accountOwnerKey = `${record.channelType}:${record.accountId}`;
const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey];
const channelOwnerId = snapshot?.channelOwners?.[record.channelType];
const ownerAgentId = accountOwnerId || channelOwnerId || null;
const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null;
const key = `${record.channelType}:${record.accountId}`;
const status = inferChannelConnectionStatus({
configured: true,
channelUrl: record.channelUrl ?? '',
status: record.channelEnabled && record.accountEnabled ? 'connected' : 'disconnected',
hasBinding: Boolean(accountOwnerId || channelOwnerId),
degraded: !record.channelEnabled || !record.accountEnabled,
});
accounts.set(key, {
id: record.accountId,
accountId: record.accountId,
channelType: record.channelType,
channelName: record.channelLabel,
channelUrl: record.channelUrl ?? '',
label: record.accountName || record.accountId,
configured: true,
enabled: record.channelEnabled && record.accountEnabled,
channelEnabled: record.channelEnabled,
status,
isDefault: record.accountId === record.defaultAccountId,
lastError: status === 'error'
? '渠道链接格式无效'
: undefined,
ownerAgentId: normalizedOwnerId,
ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null,
bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null,
});
}
const legacyChannels = getSelectedChannelsConfig();
for (const item of legacyChannels) {
const channelType = inferChannelType(item);
const key = `${channelType}:${item.id}`;
if (accounts.has(key)) continue;
const accountOwnerKey = `${channelType}:${item.id}`;
const accountOwnerId = snapshot?.channelAccountOwners?.[accountOwnerKey];
const channelOwnerId = snapshot?.channelOwners?.[channelType];
const ownerAgentId = accountOwnerId || channelOwnerId || null;
const normalizedOwnerId = ownerAgentId ? normalizeAgentId(ownerAgentId) : null;
const status = inferChannelConnectionStatus({
configured: true,
channelUrl: item.channelUrl,
status: ownerAgentId ? 'connected' : undefined,
hasBinding: Boolean(accountOwnerId || channelOwnerId),
});
accounts.set(key, {
id: item.id,
accountId: item.id,
channelType,
channelName: item.channelName,
channelUrl: item.channelUrl,
label: item.channelName,
configured: true,
enabled: true,
channelEnabled: true,
status,
isDefault: false,
lastError: status === 'error'
? '渠道链接格式无效'
: undefined,
ownerAgentId: normalizedOwnerId,
ownerAgentName: normalizedOwnerId ? agentNameById.get(normalizedOwnerId) ?? null : null,
bindingScope: accountOwnerId ? 'account' : channelOwnerId ? 'channel' : null,
});
}
return Array.from(accounts.values()).sort((left, right) => {
if (left.channelName !== right.channelName) {
return left.channelName.localeCompare(right.channelName, 'zh-CN');
}
return left.label.localeCompare(right.label, 'zh-CN');
});
}
export function listSelectedChannelAccountGroups(
snapshot?: Pick<AgentsSnapshot, 'agents' | 'channelOwners' | 'channelAccountOwners'>,
): ChannelAccountCatalogGroup[] {
const accounts = listSelectedChannelAccounts(snapshot);
const groups = new Map<string, ChannelAccountCatalogGroup>();
for (const account of accounts) {
const existing = groups.get(account.channelType) ?? {
channelType: account.channelType,
channelLabel: account.channelName || formatChannelLabel(account.channelType),
defaultAccountId: account.isDefault ? account.accountId : '',
enabled: account.channelEnabled,
status: account.status,
accounts: [],
};
existing.enabled = existing.enabled !== false && account.channelEnabled;
existing.accounts.push({
accountId: account.accountId,
name: account.label || account.channelName || account.accountId,
configured: account.configured,
enabled: account.enabled,
status: account.status,
lastError: account.lastError,
isDefault: account.isDefault,
agentId: account.ownerAgentId ?? undefined,
bindingScope: account.bindingScope ?? undefined,
channelUrl: account.channelUrl,
});
if (!existing.defaultAccountId && account.isDefault) {
existing.defaultAccountId = account.accountId;
}
if (existing.status !== 'error' && account.status === 'error') {
existing.status = 'error';
} else if (existing.status === 'disconnected' && account.status !== 'disconnected') {
existing.status = account.status;
} else if (existing.status !== 'connected' && account.status === 'connected') {
existing.status = 'connected';
}
groups.set(account.channelType, existing);
}
return Array.from(groups.values())
.map((group) => {
const sortedAccounts = [...group.accounts].sort((left, right) => left.name.localeCompare(right.name));
const defaultAccountId = group.defaultAccountId || sortedAccounts[0]?.accountId || 'default';
const status = buildChannelStatusSummary([
{
...group,
accounts: sortedAccounts,
},
]).status;
return {
...group,
defaultAccountId,
status,
accounts: sortedAccounts.map((account) => ({
...account,
isDefault: account.isDefault || account.accountId === defaultAccountId,
})),
};
})
.sort((left, right) => left.channelLabel.localeCompare(right.channelLabel));
}
export function listSelectedChannelTargets(
channelType: string,
accountId?: string | null,
query?: string | null,
): ChannelTargetCatalogItem[] {
const normalizedChannelType = String(channelType ?? '').trim();
const normalizedAccountId = String(accountId ?? '').trim();
const normalizedQuery = String(query ?? '').trim().toLowerCase();
const accounts = listSelectedChannelAccounts().filter((entry) => {
if (entry.channelType !== normalizedChannelType) return false;
if (!normalizedAccountId) return true;
return entry.accountId === normalizedAccountId;
});
const targetMap = new Map<string, ChannelTargetCatalogItem>();
for (const account of accounts) {
const displayName = account.channelName || account.label || account.accountId;
pushTargetCandidate(
targetMap,
buildTargetCandidate(displayName, displayName, {
description: '使用当前渠道账号名称作为默认发送目标',
kind: 'name',
source: 'channel-name',
channelType: account.channelType,
accountId: account.accountId,
}),
);
pushTargetCandidate(
targetMap,
buildTargetCandidate(account.accountId, `账号 ID · ${account.accountId}`, {
description: '使用当前渠道账号标识作为发送目标',
kind: 'identifier',
source: 'account-id',
channelType: account.channelType,
accountId: account.accountId,
}),
);
appendUrlTargetCandidates(account, targetMap);
}
appendHistoricalTargetCandidates(normalizedChannelType, normalizedAccountId || null, targetMap);
if (targetMap.size === 0 && normalizedAccountId) {
pushTargetCandidate(
targetMap,
buildTargetCandidate(normalizedAccountId, `账号 ID · ${normalizedAccountId}`, {
description: '当前账号没有更多可发现目标,保留账号标识作为兜底候选',
kind: 'identifier',
source: 'fallback',
channelType: normalizedChannelType,
accountId: normalizedAccountId,
}),
);
}
return Array.from(targetMap.values()).sort((left, right) => {
const kindOrder: Record<ChannelTargetKind, number> = {
name: 0,
identifier: 1,
webhook: 2,
url: 3,
};
const leftOrder = left.kind ? kindOrder[left.kind] : 99;
const rightOrder = right.kind ? kindOrder[right.kind] : 99;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return left.label.localeCompare(right.label, 'zh-CN');
}).filter((entry) => {
if (!normalizedQuery) return true;
const haystacks = [
entry.value,
entry.label,
entry.description ?? '',
].map((value) => value.toLowerCase());
return haystacks.some((value) => value.includes(normalizedQuery));
});
}