428 lines
13 KiB
TypeScript
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;
|
|
}
|