- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance. - Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons. - Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience. - Added new messages for documentation links, validation feedback, and secret management in i18n. - Extended ChannelMeta to include optional documentation URLs for better context on configuration fields. - Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving. - Introduced ChannelLogo component to display channel icons in the UI. - Added tests for channel credential validation to ensure proper error handling and feedback.
452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
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<string, unknown> {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function collectValidationValues(body: Record<string, unknown> | null | undefined): Record<string, unknown> {
|
||
const merged: Record<string, unknown> = {};
|
||
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<Record<string, unknown>>(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<string, unknown>;
|
||
metadata?: Record<string, unknown>;
|
||
}>(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;
|
||
}
|