Files
EllyDiscordBot/src/config/config.ts

428 lines
13 KiB
TypeScript

/**
* 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;
}