/** * 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 { const result: Record = {}; 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; } 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; } 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; } 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 { 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)[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)[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)[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)[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)[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(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)[part]; } return current as T; }