Files
EllyDiscordBot/src/client/EllyClient.ts
2025-12-12 16:54:00 +00:00

365 lines
10 KiB
TypeScript

/**
* 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<string, Command>();
public readonly cooldowns = new Collection<string, Collection<string, number>>();
// 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<void> {
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<void> {
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<string, ActivityType> = {
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<void> {
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<void> {
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');
}
}