import type { HostApiContext } from '../context'; import type { NormalizedHostApiRequest } from '../route-utils'; import { fail, ok, parseJsonBody } from '../route-utils'; import { assignChannelToAgent, clearAllChannelBindings, clearChannelBinding, listAgentsSnapshot, } from '../../utils/agent-config'; import { getChannelFormValues, hasStoredChannelAccount, isCanonicalChannelAccountId, listStoredChannelTypes, saveChannelConfig, setChannelDefaultAccount, setChannelEnabled, deleteChannelConfig, } from '../../utils/channel-config'; import { listSelectedChannelAccountGroups, listSelectedChannelTargets, } from '../../utils/channels'; import { getChannelMeta } from '@src/lib/channel-meta'; function getProviderSnapshot(ctx: HostApiContext) { const accounts = ctx.providerApiService .getAccounts() .filter((account) => account.enabled !== false); const defaultAccountId = ctx.providerApiService.getDefault().accountId; return { accounts, defaultAccountId }; } const VALIDATION_RESERVED_KEYS = new Set([ 'channelType', 'accountId', 'credentials', 'values', 'config', 'fields', 'success', 'valid', 'errors', 'warnings', 'details', ]); interface CredentialFieldValidationResult { key: string; label: string; kind: string; required: boolean; provided: boolean; valid: boolean; errors: string[]; warnings: string[]; } function isPlainRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function collectValidationValues(body: Record | null | undefined): Record { const merged: Record = {}; const sources = [ isPlainRecord(body?.credentials) ? body?.credentials : null, isPlainRecord(body?.values) ? body?.values : null, isPlainRecord(body?.config) ? body?.config : null, isPlainRecord(body?.fields) ? body?.fields : null, body, ]; for (const source of sources) { if (!isPlainRecord(source)) continue; for (const [key, value] of Object.entries(source)) { if (VALIDATION_RESERVED_KEYS.has(key)) continue; merged[key] = value; } } return merged; } function normalizeCredentialValue(value: unknown): string { return String(value ?? '').trim(); } function validateUrlField(label: string, value: string, result: CredentialFieldValidationResult): void { try { const parsed = new URL(value); if (parsed.protocol === 'http:') { result.warnings.push(`${label} 使用了不安全的 http URL,建议改用 https`); } } catch { result.errors.push(`${label} 必须是有效的 URL`); result.valid = false; } } function validateCredentialField( field: { key: string; label: string; kind: string; required?: boolean }, rawValue: unknown, ): CredentialFieldValidationResult { const value = normalizeCredentialValue(rawValue); const result: CredentialFieldValidationResult = { key: field.key, label: field.label, kind: field.kind, required: field.required === true, provided: value.length > 0, valid: true, errors: [], warnings: [], }; if (field.required && !value) { result.errors.push(`${field.label} is required`); result.valid = false; return result; } if (!value) { return result; } if (field.kind === 'url') { validateUrlField(field.label, value, result); return result; } if (field.kind === 'token' || field.kind === 'password') { if (/\s/.test(value)) { result.errors.push(`${field.label} 不能包含空格`); result.valid = false; } return result; } if (field.kind === 'text' || field.kind === 'textarea') { if (/[\r\n]/.test(value)) { result.warnings.push(`${field.label} 包含换行符,确认是否符合目标渠道格式`); } } return result; } export async function handleChannelRoutes( request: NormalizedHostApiRequest, ctx: HostApiContext, ) { const { pathname, method } = request; const { accounts, defaultAccountId } = getProviderSnapshot(ctx); const snapshot = listAgentsSnapshot(accounts, defaultAccountId); if (pathname === '/api/channels/accounts' && method === 'GET') { return ok({ success: true, channels: listSelectedChannelAccountGroups(snapshot), }); } if (pathname === '/api/channels/configured' && method === 'GET') { return ok({ success: true, channels: listStoredChannelTypes(), }); } if (pathname === '/api/channels/targets' && method === 'GET') { const channelType = request.url.searchParams.get('channelType')?.trim() || ''; const accountId = request.url.searchParams.get('accountId')?.trim() || null; const query = request.url.searchParams.get('query')?.trim() || null; if (!channelType) { return fail(400, 'channelType is required'); } return ok({ success: true, targets: listSelectedChannelTargets(channelType, accountId, query), }); } if (pathname === '/api/channels/credentials/validate' && method === 'POST') { try { const body = parseJsonBody>(request.body); const channelType = String(body?.channelType ?? '').trim(); const accountId = String(body?.accountId ?? '').trim() || undefined; const meta = getChannelMeta(channelType); const validationValues = collectValidationValues(body); const fieldResults = meta.configFields.map((field) => validateCredentialField(field, validationValues[field.key])); const errors = fieldResults.flatMap((field) => field.errors); const warnings = fieldResults.flatMap((field) => field.warnings); if (!channelType) { errors.unshift('channelType is required'); } return ok({ success: true, valid: errors.length === 0, errors, warnings, details: { channelType, accountId, channelName: meta.name, connectionType: meta.connectionType, fields: fieldResults, }, }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname === '/api/channels/default-account' && method === 'PUT') { try { const body = parseJsonBody<{ channelType?: string; accountId?: string | null; }>(request.body); const channelType = String(body?.channelType ?? '').trim(); const accountId = String(body?.accountId ?? '').trim(); if (!channelType) { return fail(400, 'channelType is required'); } if (!accountId) { return fail(400, 'accountId is required'); } setChannelDefaultAccount(channelType, accountId); ctx.gatewayManager.notifyRuntimeChanged({ topics: ['channels', 'channel-targets', 'agents'], reason: 'channels:default-account-updated', channelType, accountId, }); return ok({ success: true, channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname === '/api/channels/config/enabled' && method === 'PUT') { try { const body = parseJsonBody<{ channelType?: string; enabled?: boolean; }>(request.body); const channelType = String(body?.channelType ?? '').trim(); if (!channelType) { return fail(400, 'channelType is required'); } setChannelEnabled(channelType, body?.enabled !== false); ctx.gatewayManager.notifyRuntimeChanged({ topics: ['channels', 'channel-targets', 'agents'], reason: 'channels:enabled-updated', channelType, }); return ok({ success: true, channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname === '/api/channels/binding' && method === 'PUT') { try { const body = parseJsonBody<{ channelType?: string; accountId?: string | null; agentId?: string; }>(request.body); const channelType = String(body?.channelType ?? '').trim(); const accountId = String(body?.accountId ?? '').trim(); const agentId = String(body?.agentId ?? '').trim(); if (!channelType) { return fail(400, 'channelType is required'); } if (!accountId) { return fail(400, 'accountId is required'); } if (!agentId) { return fail(400, 'agentId is required'); } if (!snapshot.agents.some((agent) => agent.id === agentId)) { return fail(404, `Agent "${agentId}" not found`); } const groupedChannels = listSelectedChannelAccountGroups(snapshot); const group = groupedChannels.find((entry) => entry.channelType === channelType); if (!group || !group.accounts.some((entry) => entry.accountId === accountId)) { return fail(404, `Channel account "${channelType}:${accountId}" not found`); } const result = assignChannelToAgent(agentId, channelType, accountId, accounts, defaultAccountId); ctx.gatewayManager.notifyRuntimeChanged({ topics: ['agents', 'channels', 'channel-targets'], reason: 'channels:binding-updated', channelType, accountId, }); return ok({ success: true, ...result, }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname === '/api/channels/config' && method === 'POST') { try { const body = parseJsonBody<{ channelType?: string; accountId?: string | null; channelLabel?: string | null; accountName?: string | null; channelUrl?: string | null; enabled?: boolean; config?: Record; metadata?: Record; }>(request.body); const channelType = String(body?.channelType ?? '').trim(); const accountId = String(body?.accountId ?? '').trim(); if (!channelType) { return fail(400, 'channelType is required'); } if (accountId && !isCanonicalChannelAccountId(accountId) && !hasStoredChannelAccount(channelType, accountId)) { return fail(400, 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'); } saveChannelConfig({ channelType, accountId: accountId || undefined, channelLabel: body?.channelLabel, accountName: body?.accountName, channelUrl: body?.channelUrl, enabled: body?.enabled, config: body?.config, metadata: body?.metadata, }); ctx.gatewayManager.notifyRuntimeChanged({ topics: ['channels', 'channel-targets', 'agents'], reason: 'channels:config-saved', channelType, accountId: accountId || undefined, }); return ok({ success: true, channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname === '/api/channels/binding' && method === 'DELETE') { try { const body = parseJsonBody<{ channelType?: string; accountId?: string | null; }>(request.body); const channelType = String(body?.channelType ?? '').trim(); const accountId = String(body?.accountId ?? '').trim(); if (!channelType) { return fail(400, 'channelType is required'); } if (!accountId) { return fail(400, 'accountId is required'); } const result = clearChannelBinding(channelType, accountId, accounts, defaultAccountId); ctx.gatewayManager.notifyRuntimeChanged({ topics: ['agents', 'channels', 'channel-targets'], reason: 'channels:binding-cleared', channelType, accountId, }); return ok({ success: true, ...result, }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname.startsWith('/api/channels/config/') && method === 'GET') { try { const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length)); const accountId = request.url.searchParams.get('accountId')?.trim() || null; return ok({ success: true, values: getChannelFormValues(channelType, accountId) ?? {}, }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } if (pathname.startsWith('/api/channels/config/') && method === 'DELETE') { try { const channelType = decodeURIComponent(pathname.slice('/api/channels/config/'.length)); const accountId = request.url.searchParams.get('accountId')?.trim() || null; deleteChannelConfig(channelType, accountId); if (accountId) { clearChannelBinding(channelType, accountId, accounts, defaultAccountId); } else { clearAllChannelBindings(channelType, accounts, defaultAccountId); } ctx.gatewayManager.notifyRuntimeChanged({ topics: ['channels', 'channel-targets', 'agents'], reason: 'channels:config-deleted', channelType, accountId: accountId || undefined, }); return ok({ success: true, channels: listSelectedChannelAccountGroups(listAgentsSnapshot(accounts, defaultAccountId)), }); } catch (error) { return fail(500, error instanceof Error ? error.message : String(error)); } } return null; }