365 lines
10 KiB
TypeScript
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');
|
|
}
|
|
}
|