Files
zn-ai/electron/utils/channel-config.ts
duanshuwen ef46c73c3e Add unit tests for channel utilities and configure testing environment
- 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.
2026-04-18 16:12:49 +08:00

392 lines
12 KiB
TypeScript

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<string, unknown>;
metadata?: Record<string, unknown>;
createdAt?: string;
updatedAt?: string;
}
export interface StoredChannelEntry {
channelType: string;
channelLabel?: string | null;
defaultAccountId?: string | null;
enabled?: boolean;
accounts?: Record<string, StoredChannelAccountEntry>;
createdAt?: string;
updatedAt?: string;
}
interface StoredChannelsDocument {
channels?: Record<string, StoredChannelEntry>;
}
export interface StoredChannelAccountRecord {
channelType: string;
channelLabel: string;
defaultAccountId: string;
channelEnabled: boolean;
accountId: string;
accountName: string;
accountEnabled: boolean;
channelUrl?: string;
config: Record<string, unknown>;
metadata: Record<string, unknown>;
}
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<string, unknown> | undefined): Record<string, string> | undefined {
if (!config || typeof config !== 'object') return undefined;
const values: Record<string, string> = {};
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<string, string> | 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<string, unknown>;
metadata?: Record<string, unknown>;
}): 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);
}