/** * Elly Discord Bot Client * Extended Discord.js Client with custom functionality */ import { Client, Collection, GatewayIntentBits, Partials, ActivityType, type TextChannel, type Role, type Guild, } from 'discord.js'; import type { Config } from '../config/types.ts'; import type { Command } from '../types/index.ts'; import { PikaNetworkAPI } from '../api/pika/index.ts'; import { JsonDatabase } from '../database/connection.ts'; import { DatabaseManager, createDatabaseManager } from '../database/DatabaseManager.ts'; import { PermissionService } from '../services/PermissionService.ts'; import { Logger, createLogger } from '../utils/logger.ts'; import { getErrorHandler, type ErrorHandler } from '../utils/errors.ts'; /** * Extended Discord.js Client for Elly */ export class EllyClient extends Client { // Configuration public readonly config: Config; // Services public readonly pikaAPI: PikaNetworkAPI; public readonly database: JsonDatabase; // Legacy JSON database public dbManager: DatabaseManager | null = null; // New SQLite database public readonly permissions: PermissionService; public readonly logger: Logger; public readonly errorHandler: ErrorHandler; // Collections public readonly commands = new Collection(); public readonly cooldowns = new Collection>(); // Cached references public mainGuild: Guild | null = null; // Cached roles public roles: { admin: Role | null; leader: Role | null; officer: Role | null; developer: Role | null; guildMember: Role | null; champion: Role | null; away: Role | null; applicationsBlacklisted: Role | null; suggestionsBlacklisted: Role | null; } = { admin: null, leader: null, officer: null, developer: null, guildMember: null, champion: null, away: null, applicationsBlacklisted: null, suggestionsBlacklisted: null, }; // Cached channels public channels_cache: { applications: TextChannel | null; applicationLogs: TextChannel | null; suggestions: TextChannel | null; suggestionLogs: TextChannel | null; guildUpdates: TextChannel | null; discordChangelog: TextChannel | null; inactivity: TextChannel | null; developmentLogs: TextChannel | null; donations: TextChannel | null; reminders: TextChannel | null; } = { applications: null, applicationLogs: null, suggestions: null, suggestionLogs: null, guildUpdates: null, discordChangelog: null, inactivity: null, developmentLogs: null, donations: null, reminders: null, }; // State private refreshInterval: number | undefined; constructor(config: Config) { super({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, ], partials: [ Partials.Message, Partials.Channel, Partials.Reaction, Partials.User, Partials.GuildMember, ], }); this.config = config; // Initialize services this.logger = createLogger('Elly', { level: config.logging.level, logFile: config.logging.file, }); this.pikaAPI = new PikaNetworkAPI({ timeout: config.api.pika_request_timeout, cache: { profileTTL: config.api.pika_cache_ttl, leaderboardTTL: Math.floor(config.api.pika_cache_ttl / 2), // Shorter TTL for leaderboards }, }); this.database = new JsonDatabase(config.database.path.replace('.db', '.json')); this.permissions = new PermissionService(config); this.errorHandler = getErrorHandler(); } /** * Initialize the bot */ async initialize(): Promise { this.logger.info('Initializing Elly...'); // Load legacy JSON database (for migration) await this.database.load(); this.logger.info('Legacy JSON database loaded'); // Initialize SQLite database try { const sqlitePath = this.config.database.path.replace('.json', '.sqlite'); this.dbManager = await createDatabaseManager(sqlitePath); this.logger.info('SQLite database initialized'); } catch (error) { this.logger.error('Failed to initialize SQLite database:', error); // Continue with JSON database as fallback } // Set up event handlers this.setupEventHandlers(); // Start refresh interval this.startRefreshInterval(); this.logger.info('Initialization complete'); } /** * Set up core event handlers */ private setupEventHandlers(): void { this.once('ready', () => this.onReady()); this.on('error', (error) => this.logger.error('Client error', error)); this.on('warn', (warning) => this.logger.warn('Client warning', warning)); } /** * Handle ready event */ private async onReady(): Promise { this.logger.info(`Logged in as ${this.user?.tag}`); // Set presence this.user?.setPresence({ activities: [ { name: this.config.bot.status, type: this.getActivityType(this.config.bot.activity_type), }, ], status: 'online', }); // Cache guild and roles await this.refreshCache(); this.logger.info('Elly is ready!'); } /** * Get Discord.js ActivityType from config string */ private getActivityType(type: string): ActivityType { const types: Record = { playing: ActivityType.Playing, streaming: ActivityType.Streaming, listening: ActivityType.Listening, watching: ActivityType.Watching, competing: ActivityType.Competing, }; return types[type] ?? ActivityType.Watching; } /** * Refresh cached guild, roles, and channels */ async refreshCache(): Promise { try { // Get main guild this.mainGuild = await this.guilds.fetch(this.config.guild.id); if (!this.mainGuild) { this.logger.error('Could not find main guild'); return; } // Cache roles const roleConfig = this.config.roles; this.roles = { admin: this.findRole(roleConfig.admin), leader: this.findRole(roleConfig.leader), officer: this.findRole(roleConfig.officer), developer: this.findRole(roleConfig.developer), guildMember: this.findRole(roleConfig.guild_member), champion: this.findRole(roleConfig.champion), away: this.findRole(roleConfig.away), applicationsBlacklisted: this.findRole(roleConfig.applications_blacklisted), suggestionsBlacklisted: this.findRole(roleConfig.suggestions_blacklisted), }; // Cache channels const channelConfig = this.config.channels; this.channels_cache = { applications: this.findChannel(channelConfig.applications), applicationLogs: this.findChannel(channelConfig.application_logs), suggestions: this.findChannel(channelConfig.suggestions), suggestionLogs: this.findChannel(channelConfig.suggestion_logs), guildUpdates: this.findChannel(channelConfig.guild_updates), discordChangelog: this.findChannel(channelConfig.discord_changelog), inactivity: this.findChannel(channelConfig.inactivity), developmentLogs: this.findChannel(channelConfig.development_logs), donations: this.findChannel(channelConfig.donations), reminders: this.findChannel(channelConfig.reminders), }; this.logger.debug('Cache refreshed'); } catch (error) { this.logger.error('Failed to refresh cache', error); } } /** * Find a role by name in the main guild */ private findRole(name: string): Role | null { if (!this.mainGuild) return null; return this.mainGuild.roles.cache.find( (role) => role.name.toLowerCase() === name.toLowerCase() ) ?? null; } /** * Find a channel by name in the main guild */ private findChannel(name: string): TextChannel | null { if (!this.mainGuild) return null; const channel = this.mainGuild.channels.cache.find( (ch) => ch.name.toLowerCase() === name.toLowerCase() ); return channel?.isTextBased() ? (channel as TextChannel) : null; } /** * Start the cache refresh interval */ private startRefreshInterval(): void { // Refresh cache every 10 minutes this.refreshInterval = setInterval(() => { this.refreshCache(); }, 600000); // Clear PikaNetwork API cache every hour setInterval(() => { this.pikaAPI.clearCache(); this.logger.debug('PikaNetwork API cache cleared'); }, 3600000); } /** * Register a command */ registerCommand(command: Command): void { this.commands.set(command.data.name, command); this.logger.debug(`Registered command: ${command.data.name}`); } /** * Check if a user is on cooldown for a command */ isOnCooldown(userId: string, commandName: string): number { const commandCooldowns = this.cooldowns.get(commandName); if (!commandCooldowns) return 0; const expiresAt = commandCooldowns.get(userId); if (!expiresAt) return 0; const now = Date.now(); if (now < expiresAt) { return Math.ceil((expiresAt - now) / 1000); } commandCooldowns.delete(userId); return 0; } /** * Set a cooldown for a user on a command */ setCooldown(userId: string, commandName: string, seconds: number): void { if (!this.cooldowns.has(commandName)) { this.cooldowns.set(commandName, new Collection()); } const commandCooldowns = this.cooldowns.get(commandName)!; commandCooldowns.set(userId, Date.now() + seconds * 1000); } /** * Graceful shutdown */ async shutdown(): Promise { this.logger.info('Shutting down...'); // Clear intervals if (this.refreshInterval) { clearInterval(this.refreshInterval); } // Close SQLite database if (this.dbManager) { this.dbManager.close(); } // Save legacy JSON database await this.database.close(); // Destroy API client this.pikaAPI.destroy(); // Destroy Discord client this.destroy(); this.logger.info('Shutdown complete'); } }