- 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.
532 lines
18 KiB
TypeScript
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));
|
|
});
|
|
}
|