import * as fs from 'fs'; import * as path from 'path'; import { getUserDataDir } from './paths'; export const CHANNEL_STORE_FILE_NAME = 'channels.json'; export const DEFAULT_CHANNEL_ACCOUNT_ID = 'default'; export interface StoredChannelAccountEntry { accountId: string; name?: string | null; channelUrl?: string | null; enabled?: boolean; config?: Record; metadata?: Record; createdAt?: string; updatedAt?: string; } export interface StoredChannelEntry { channelType: string; channelLabel?: string | null; defaultAccountId?: string | null; enabled?: boolean; accounts?: Record; createdAt?: string; updatedAt?: string; } interface StoredChannelsDocument { channels?: Record; } export interface StoredChannelAccountRecord { channelType: string; channelLabel: string; defaultAccountId: string; channelEnabled: boolean; accountId: string; accountName: string; accountEnabled: boolean; channelUrl?: string; config: Record; metadata: Record; } function getStorePath(): string { return path.join(getUserDataDir(), CHANNEL_STORE_FILE_NAME); } function formatChannelLabel(channelType: string, fallback?: string | null): string { const preferred = String(fallback ?? '').trim(); if (preferred) return preferred; const parts = String(channelType ?? '') .split(/[-_]/) .map((part) => part.trim()) .filter(Boolean); if (parts.length === 0) { return String(channelType ?? '').trim(); } return parts .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' '); } function normalizeChannelType(value: string): string { return String(value ?? '').trim().toLowerCase(); } function normalizeAccountId(value: string | null | undefined): string { const trimmed = String(value ?? '').trim(); return trimmed || DEFAULT_CHANNEL_ACCOUNT_ID; } function ensureStoreDir(): void { fs.mkdirSync(path.dirname(getStorePath()), { recursive: true }); } function readStore(): StoredChannelsDocument { try { const filePath = getStorePath(); if (!fs.existsSync(filePath)) { return { channels: {} }; } const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as StoredChannelsDocument; return { channels: parsed.channels && typeof parsed.channels === 'object' ? parsed.channels : {}, }; } catch { return { channels: {} }; } } function writeStore(store: StoredChannelsDocument): void { ensureStoreDir(); fs.writeFileSync(getStorePath(), JSON.stringify(store, null, 2), 'utf-8'); } function ensureChannelEntry( store: StoredChannelsDocument, channelType: string, ): StoredChannelEntry { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) { throw new Error('channelType is required'); } if (!store.channels) { store.channels = {}; } const existing = store.channels[normalizedChannelType]; if (existing) { if (!existing.accounts || typeof existing.accounts !== 'object') { existing.accounts = {}; } existing.channelType = normalizedChannelType; existing.channelLabel = formatChannelLabel(normalizedChannelType, existing.channelLabel); existing.defaultAccountId = normalizeAccountId(existing.defaultAccountId); existing.enabled = existing.enabled !== false; return existing; } const now = new Date().toISOString(); const created: StoredChannelEntry = { channelType: normalizedChannelType, channelLabel: formatChannelLabel(normalizedChannelType), defaultAccountId: DEFAULT_CHANNEL_ACCOUNT_ID, enabled: true, accounts: {}, createdAt: now, updatedAt: now, }; store.channels[normalizedChannelType] = created; return created; } function ensureAccountEntry( channel: StoredChannelEntry, accountId: string, ): StoredChannelAccountEntry { const normalizedAccountId = normalizeAccountId(accountId); if (!channel.accounts || typeof channel.accounts !== 'object') { channel.accounts = {}; } const existing = channel.accounts[normalizedAccountId]; if (existing) { existing.accountId = normalizedAccountId; existing.enabled = existing.enabled !== false; return existing; } const now = new Date().toISOString(); const created: StoredChannelAccountEntry = { accountId: normalizedAccountId, name: normalizedAccountId, enabled: true, config: {}, metadata: {}, createdAt: now, updatedAt: now, }; channel.accounts[normalizedAccountId] = created; return created; } function coerceFormValues(config: Record | undefined): Record | undefined { if (!config || typeof config !== 'object') return undefined; const values: Record = {}; for (const [key, value] of Object.entries(config)) { if (value == null) continue; if (typeof value === 'string') { values[key] = value; continue; } if (typeof value === 'number' || typeof value === 'boolean') { values[key] = String(value); } } return Object.keys(values).length > 0 ? values : undefined; } export function isCanonicalChannelAccountId(value: string): boolean { return /^[a-z0-9](?:[a-z0-9_-]{0,63})$/.test(String(value ?? '').trim()); } export function listStoredChannelTypes(): string[] { return Object.keys(readStore().channels ?? {}).sort((left, right) => left.localeCompare(right, 'zh-CN')); } export function hasStoredChannelAccount(channelType: string, accountId?: string | null): boolean { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) return false; const channel = readStore().channels?.[normalizedChannelType]; if (!channel) return false; const normalizedAccountId = normalizeAccountId(accountId); return Boolean(channel.accounts?.[normalizedAccountId]); } export function listStoredChannelAccountRecords(): StoredChannelAccountRecord[] { const store = readStore(); const records: StoredChannelAccountRecord[] = []; for (const [rawChannelType, rawChannel] of Object.entries(store.channels ?? {})) { const channelType = normalizeChannelType(rawChannelType); if (!channelType || !rawChannel || typeof rawChannel !== 'object') continue; const channelLabel = formatChannelLabel(channelType, rawChannel.channelLabel); const channelEnabled = rawChannel.enabled !== false; const accounts = rawChannel.accounts && typeof rawChannel.accounts === 'object' ? rawChannel.accounts : {}; const sortedAccountIds = Object.keys(accounts).sort((left, right) => { if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1; if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1; return left.localeCompare(right, 'zh-CN'); }); const defaultAccountId = normalizeAccountId( rawChannel.defaultAccountId && sortedAccountIds.includes(normalizeAccountId(rawChannel.defaultAccountId)) ? rawChannel.defaultAccountId : sortedAccountIds[0], ); for (const accountId of sortedAccountIds) { const account = accounts[accountId]; if (!account || typeof account !== 'object') continue; records.push({ channelType, channelLabel, defaultAccountId, channelEnabled, accountId, accountName: String(account.name ?? accountId).trim() || accountId, accountEnabled: account.enabled !== false, channelUrl: typeof account.channelUrl === 'string' ? account.channelUrl : undefined, config: account.config && typeof account.config === 'object' ? account.config : {}, metadata: account.metadata && typeof account.metadata === 'object' ? account.metadata : {}, }); } } return records.sort((left, right) => { if (left.channelLabel !== right.channelLabel) { return left.channelLabel.localeCompare(right.channelLabel, 'zh-CN'); } return left.accountName.localeCompare(right.accountName, 'zh-CN'); }); } export function getChannelFormValues( channelType: string, accountId?: string | null, ): Record | undefined { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) return undefined; const channel = readStore().channels?.[normalizedChannelType]; if (!channel) return undefined; const account = channel.accounts?.[normalizeAccountId(accountId)]; if (!account) return undefined; return coerceFormValues(account.config); } export function saveChannelConfig(input: { channelType: string; accountId?: string | null; channelLabel?: string | null; accountName?: string | null; channelUrl?: string | null; enabled?: boolean; config?: Record; metadata?: Record; }): StoredChannelEntry { const normalizedChannelType = normalizeChannelType(input.channelType); if (!normalizedChannelType) { throw new Error('channelType is required'); } const normalizedAccountId = normalizeAccountId(input.accountId); const store = readStore(); const channel = ensureChannelEntry(store, normalizedChannelType); const account = ensureAccountEntry(channel, normalizedAccountId); const now = new Date().toISOString(); channel.channelLabel = formatChannelLabel(normalizedChannelType, input.channelLabel ?? channel.channelLabel); channel.enabled = input.enabled ?? channel.enabled ?? true; channel.defaultAccountId = normalizeAccountId(channel.defaultAccountId || normalizedAccountId); channel.updatedAt = now; account.name = String(input.accountName ?? account.name ?? normalizedAccountId).trim() || normalizedAccountId; account.channelUrl = typeof input.channelUrl === 'string' ? input.channelUrl.trim() || undefined : account.channelUrl ?? undefined; account.enabled = input.enabled ?? account.enabled ?? true; account.config = input.config && typeof input.config === 'object' ? input.config : {}; account.metadata = input.metadata && typeof input.metadata === 'object' ? input.metadata : (account.metadata ?? {}); account.updatedAt = now; if (!channel.accounts || Object.keys(channel.accounts).length === 1) { channel.defaultAccountId = normalizedAccountId; } writeStore(store); return channel; } export function setChannelDefaultAccount(channelType: string, accountId: string): StoredChannelEntry { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) { throw new Error('channelType is required'); } const normalizedAccountId = normalizeAccountId(accountId); const store = readStore(); const channel = ensureChannelEntry(store, normalizedChannelType); if (!channel.accounts?.[normalizedAccountId]) { throw new Error(`Channel account "${normalizedChannelType}:${normalizedAccountId}" not found`); } channel.defaultAccountId = normalizedAccountId; channel.updatedAt = new Date().toISOString(); writeStore(store); return channel; } export function setChannelEnabled(channelType: string, enabled: boolean): StoredChannelEntry { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) { throw new Error('channelType is required'); } const store = readStore(); const channel = ensureChannelEntry(store, normalizedChannelType); channel.enabled = Boolean(enabled); channel.updatedAt = new Date().toISOString(); writeStore(store); return channel; } export function deleteChannelConfig(channelType: string, accountId?: string | null): void { const normalizedChannelType = normalizeChannelType(channelType); if (!normalizedChannelType) { throw new Error('channelType is required'); } const store = readStore(); const channels = store.channels ?? {}; const channel = channels[normalizedChannelType]; if (!channel) return; const normalizedAccountId = accountId == null ? '' : normalizeAccountId(accountId); if (!normalizedAccountId) { delete channels[normalizedChannelType]; writeStore(store); return; } if (channel.accounts?.[normalizedAccountId]) { delete channel.accounts[normalizedAccountId]; } const remainingAccountIds = Object.keys(channel.accounts ?? {}); if (remainingAccountIds.length === 0) { delete channels[normalizedChannelType]; writeStore(store); return; } if (!remainingAccountIds.includes(normalizeAccountId(channel.defaultAccountId))) { channel.defaultAccountId = remainingAccountIds.sort((left, right) => { if (left === DEFAULT_CHANNEL_ACCOUNT_ID) return -1; if (right === DEFAULT_CHANNEL_ACCOUNT_ID) return 1; return left.localeCompare(right, 'zh-CN'); })[0]; } channel.updatedAt = new Date().toISOString(); writeStore(store); }