- 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.
392 lines
12 KiB
TypeScript
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);
|
|
}
|