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 = { douyin: 'douyin', '抖音': 'douyin', fliggy: 'fliggy', '飞猪': 'fliggy', meituan: 'meituan', '美团': 'meituan', }; const CHANNEL_HOST_TYPE_ALIASES: Record = { '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 = { 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, 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, targetMap: Map, ): 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, ): 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 | 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(CONFIG_KEYS.SELECTED_CHANNELS); if (!Array.isArray(saved)) return []; const deduped = new Map(); 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): LocalChannelAccount[] { const agentNameById = new Map( Array.isArray(snapshot?.agents) ? snapshot.agents.map((agent) => [normalizeAgentId(agent.id), agent.name || normalizeAgentId(agent.id)]) : [], ); const accounts = new Map(); 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, ): ChannelAccountCatalogGroup[] { const accounts = listSelectedChannelAccounts(snapshot); const groups = new Map(); 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(); 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 = { 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)); }); }