(Feat): Added a minimal pikanetwork client
This commit is contained in:
427
src/config/config.ts
Normal file
427
src/config/config.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Configuration loader for Elly Discord Bot
|
||||
* Parses TOML configuration file and provides typed access
|
||||
*/
|
||||
|
||||
import type { Config } from './types.ts';
|
||||
|
||||
// Validation error types
|
||||
export interface ConfigValidationError {
|
||||
field: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
valid: boolean;
|
||||
errors: ConfigValidationError[];
|
||||
warnings: ConfigValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple TOML parser for configuration files
|
||||
* Handles basic TOML structures: strings, numbers, booleans, arrays, and tables
|
||||
*/
|
||||
function parseTOML(content: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
const lines = content.split('\n');
|
||||
let currentSection: string[] = [];
|
||||
let multiLineArray: { key: string; content: string } | null = null;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trim();
|
||||
|
||||
// Skip empty lines and comments (unless in multi-line array)
|
||||
if (!multiLineArray && (!line || line.startsWith('#'))) continue;
|
||||
|
||||
// Handle multi-line array continuation
|
||||
if (multiLineArray) {
|
||||
// Remove comments from array lines
|
||||
const commentIdx = line.indexOf('#');
|
||||
if (commentIdx > 0) {
|
||||
line = line.substring(0, commentIdx).trim();
|
||||
}
|
||||
|
||||
multiLineArray.content += ' ' + line;
|
||||
|
||||
// Check if array is complete
|
||||
if (line.includes(']')) {
|
||||
const parsedValue = parseValue(multiLineArray.content.trim());
|
||||
|
||||
// Set the value in the correct location
|
||||
let current = result;
|
||||
for (const part of currentSection) {
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[multiLineArray.key] = parsedValue;
|
||||
multiLineArray = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove inline comments (but not inside strings)
|
||||
const commentIndex = line.indexOf('#');
|
||||
if (commentIndex > 0) {
|
||||
// Check if # is inside a string
|
||||
const beforeComment = line.substring(0, commentIndex);
|
||||
const quoteCount = (beforeComment.match(/"/g) || []).length;
|
||||
if (quoteCount % 2 === 0) {
|
||||
line = beforeComment.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle section headers [section] or [section.subsection]
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
const sectionPath = line.slice(1, -1).trim();
|
||||
currentSection = sectionPath.split('.');
|
||||
|
||||
// Ensure nested structure exists
|
||||
let current = result;
|
||||
for (const part of currentSection) {
|
||||
if (!(part in current)) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle key-value pairs
|
||||
const equalsIndex = line.indexOf('=');
|
||||
if (equalsIndex > 0) {
|
||||
const key = line.substring(0, equalsIndex).trim();
|
||||
let value = line.substring(equalsIndex + 1).trim();
|
||||
|
||||
// Check for multi-line array
|
||||
if (value.startsWith('[') && !value.includes(']')) {
|
||||
multiLineArray = { key, content: value };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse the value
|
||||
const parsedValue = parseValue(value);
|
||||
|
||||
// Set the value in the correct location
|
||||
let current = result;
|
||||
for (const part of currentSection) {
|
||||
current = current[part] as Record<string, unknown>;
|
||||
}
|
||||
current[key] = parsedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a TOML value string into its JavaScript equivalent
|
||||
*/
|
||||
function parseValue(value: string): unknown {
|
||||
value = value.trim();
|
||||
|
||||
// String (double-quoted)
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
// String (single-quoted - literal)
|
||||
if (value.startsWith("'") && value.endsWith("'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
// Array
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
const arrayContent = value.slice(1, -1).trim();
|
||||
if (!arrayContent) return [];
|
||||
|
||||
const items: unknown[] = [];
|
||||
let current = '';
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
|
||||
for (let i = 0; i < arrayContent.length; i++) {
|
||||
const char = arrayContent[i];
|
||||
|
||||
if (!inString && (char === '"' || char === "'")) {
|
||||
inString = true;
|
||||
stringChar = char;
|
||||
current += char;
|
||||
} else if (inString && char === stringChar && arrayContent[i - 1] !== '\\') {
|
||||
inString = false;
|
||||
current += char;
|
||||
} else if (!inString && char === '[') {
|
||||
depth++;
|
||||
current += char;
|
||||
} else if (!inString && char === ']') {
|
||||
depth--;
|
||||
current += char;
|
||||
} else if (!inString && char === ',' && depth === 0) {
|
||||
items.push(parseValue(current.trim()));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
items.push(parseValue(current.trim()));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Boolean
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
// Hexadecimal number
|
||||
if (value.startsWith('0x') || value.startsWith('0X')) {
|
||||
return parseInt(value, 16);
|
||||
}
|
||||
|
||||
// Number (integer or float)
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return value.includes('.') ? parseFloat(value) : parseInt(value, 10);
|
||||
}
|
||||
|
||||
// Return as string if nothing else matches
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse the configuration file
|
||||
*/
|
||||
export async function loadConfig(path: string = './config.toml'): Promise<Config> {
|
||||
try {
|
||||
const content = await Deno.readTextFile(path);
|
||||
const parsed = parseTOML(content);
|
||||
return parsed as unknown as Config;
|
||||
} catch (error) {
|
||||
if (error instanceof Deno.errors.NotFound) {
|
||||
throw new Error(`Configuration file not found: ${path}`);
|
||||
}
|
||||
throw new Error(`Failed to load configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the configuration object with detailed error reporting
|
||||
*/
|
||||
export function validateConfig(config: Config): ConfigValidationResult {
|
||||
const errors: ConfigValidationError[] = [];
|
||||
const warnings: ConfigValidationError[] = [];
|
||||
|
||||
// Helper to check if a value exists at a path
|
||||
const getValue = (path: string): unknown => {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = config;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
// Helper to add error
|
||||
const addError = (field: string, message: string) => {
|
||||
errors.push({ field, message, severity: 'error' });
|
||||
};
|
||||
|
||||
// Helper to add warning
|
||||
const addWarning = (field: string, message: string) => {
|
||||
warnings.push({ field, message, severity: 'warning' });
|
||||
};
|
||||
|
||||
// ===== Required Fields =====
|
||||
const requiredFields = [
|
||||
{ path: 'bot.name', type: 'string', description: 'Bot name' },
|
||||
{ path: 'bot.prefix', type: 'string', description: 'Command prefix' },
|
||||
{ path: 'database.path', type: 'string', description: 'Database path' },
|
||||
{ path: 'guild.id', type: 'string', description: 'Guild ID' },
|
||||
];
|
||||
|
||||
for (const { path, type, description } of requiredFields) {
|
||||
const value = getValue(path);
|
||||
if (value === undefined || value === null) {
|
||||
addError(path, `Missing required field: ${description}`);
|
||||
} else if (typeof value !== type) {
|
||||
addError(path, `Invalid type for ${description}: expected ${type}, got ${typeof value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Bot Configuration =====
|
||||
if (config.bot) {
|
||||
// Validate bot name length
|
||||
if (config.bot.name && config.bot.name.length > 32) {
|
||||
addError('bot.name', 'Bot name must be 32 characters or less');
|
||||
}
|
||||
|
||||
// Validate activity type
|
||||
const validActivityTypes = ['playing', 'streaming', 'listening', 'watching', 'competing'];
|
||||
if (config.bot.activity_type && !validActivityTypes.includes(config.bot.activity_type)) {
|
||||
addError('bot.activity_type', `Invalid activity type. Must be one of: ${validActivityTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate owners
|
||||
if (!config.bot.owners || !config.bot.owners.ids || config.bot.owners.ids.length === 0) {
|
||||
addWarning('bot.owners', 'No bot owners configured. Some commands may be inaccessible.');
|
||||
} else {
|
||||
for (const ownerId of config.bot.owners.ids) {
|
||||
if (!/^\d{17,19}$/.test(ownerId)) {
|
||||
addError('bot.owners.ids', `Invalid Discord user ID format: ${ownerId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Guild Configuration =====
|
||||
if (config.guild) {
|
||||
// Validate guild ID format
|
||||
if (config.guild.id && !/^\d{17,19}$/.test(config.guild.id)) {
|
||||
addError('guild.id', 'Invalid Discord guild ID format');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Database Configuration =====
|
||||
if (config.database) {
|
||||
// Validate database path
|
||||
if (config.database.path) {
|
||||
if (!config.database.path.endsWith('.db') && !config.database.path.endsWith('.sqlite')) {
|
||||
addWarning('database.path', 'Database path should end with .db or .sqlite');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API Configuration =====
|
||||
if (config.api) {
|
||||
if (config.api.pika_cache_ttl !== undefined && config.api.pika_cache_ttl < 0) {
|
||||
addError('api.pika_cache_ttl', 'Cache TTL must be a positive number');
|
||||
}
|
||||
if (config.api.pika_request_timeout !== undefined && config.api.pika_request_timeout < 1000) {
|
||||
addWarning('api.pika_request_timeout', 'Request timeout is very low (< 1000ms)');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Channels Configuration =====
|
||||
if (config.channels) {
|
||||
const channelFields = [
|
||||
'applications', 'application_logs', 'suggestions', 'suggestion_logs',
|
||||
'guild_updates', 'discord_changelog', 'inactivity', 'development_logs',
|
||||
'donations', 'reminders'
|
||||
];
|
||||
|
||||
for (const field of channelFields) {
|
||||
const value = (config.channels as Record<string, unknown>)[field];
|
||||
if (!value) {
|
||||
addWarning(`channels.${field}`, `Channel not configured: ${field}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addWarning('channels', 'No channels configured');
|
||||
}
|
||||
|
||||
// ===== Roles Configuration =====
|
||||
if (config.roles) {
|
||||
const roleFields = [
|
||||
'admin', 'leader', 'officer', 'developer', 'guild_member',
|
||||
'champion', 'away', 'applications_blacklisted', 'suggestions_blacklisted'
|
||||
];
|
||||
|
||||
for (const field of roleFields) {
|
||||
const value = (config.roles as Record<string, unknown>)[field];
|
||||
if (!value) {
|
||||
addWarning(`roles.${field}`, `Role not configured: ${field}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addWarning('roles', 'No roles configured');
|
||||
}
|
||||
|
||||
// ===== Features Configuration =====
|
||||
if (config.features) {
|
||||
const featureFields = [
|
||||
'applications', 'suggestions', 'statistics', 'family', 'qotd',
|
||||
'reminders', 'staff_simulator', 'channel_filtering', 'auto_moderation',
|
||||
'welcome_system', 'level_system'
|
||||
];
|
||||
|
||||
for (const field of featureFields) {
|
||||
const value = (config.features as Record<string, unknown>)[field];
|
||||
if (value === undefined) {
|
||||
addWarning(`features.${field}`, `Feature flag not set: ${field} (defaulting to false)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Limits Configuration =====
|
||||
if (config.limits) {
|
||||
if (config.limits.champion_max_days !== undefined && config.limits.champion_max_days < 1) {
|
||||
addError('limits.champion_max_days', 'Champion max days must be at least 1');
|
||||
}
|
||||
if (config.limits.away_max_days !== undefined && config.limits.away_max_days < 1) {
|
||||
addError('limits.away_max_days', 'Away max days must be at least 1');
|
||||
}
|
||||
if (config.limits.purge_max_messages !== undefined) {
|
||||
if (config.limits.purge_max_messages < 1 || config.limits.purge_max_messages > 100) {
|
||||
addError('limits.purge_max_messages', 'Purge max messages must be between 1 and 100');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Colors Configuration =====
|
||||
if (config.colors) {
|
||||
const colorFields = ['primary', 'success', 'warning', 'error', 'info'];
|
||||
for (const field of colorFields) {
|
||||
const value = (config.colors as Record<string, unknown>)[field];
|
||||
if (value !== undefined && (typeof value !== 'number' || value < 0 || value > 0xFFFFFF)) {
|
||||
addError(`colors.${field}`, `Invalid color value: must be a number between 0 and 16777215 (0xFFFFFF)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Logging Configuration =====
|
||||
if (config.logging) {
|
||||
const validLogLevels = ['debug', 'info', 'warn', 'error'];
|
||||
if (config.logging.level && !validLogLevels.includes(config.logging.level)) {
|
||||
addError('logging.level', `Invalid log level. Must be one of: ${validLogLevels.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate config and throw if invalid (for backwards compatibility)
|
||||
*/
|
||||
export function validateConfigOrThrow(config: Config): void {
|
||||
const result = validateConfig(config);
|
||||
|
||||
if (!result.valid) {
|
||||
const errorMessages = result.errors.map((e) => ` - ${e.field}: ${e.message}`).join('\n');
|
||||
throw new Error(`Configuration validation failed:\n${errorMessages}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value by path (e.g., "bot.name")
|
||||
*/
|
||||
export function getConfigValue<T>(config: Config, path: string): T | undefined {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = config;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return current as T;
|
||||
}
|
||||
Reference in New Issue
Block a user