UNPKG

@zerospacegg/vynthra

Version:
344 lines (291 loc) • 11 kB
import { ChatInputCommandInteraction, Client, Collection, Events, GatewayIntentBits, SlashCommandBuilder, } from "discord.js"; import { EventEmitter } from "events"; import { deployCommands } from "./deploy.js"; // import { statsSubcommand } from "./subcommands/stats.js"; import util from "util"; import type { BotClient, BotCommand, BotConfig, BotSubcommand } from "./types.js"; import { validateSubcommands } from "./utils.js"; export interface CommandUsageEvent { commandName: string; subcommand?: string; userTag: string; userId: string; guildId?: string; guildName?: string; channelType: string; success: boolean; errorMessage?: string; responseTime: number; isEphemeral: boolean; timestamp: Date; } export class VynthraBot { private client: BotClient; private config: BotConfig; private eventEmitter: EventEmitter; constructor(config: BotConfig) { this.config = config; this.eventEmitter = new EventEmitter(); // Validate custom subcommands if provided if (config.subcommands && config.subcommands.length > 0) { validateSubcommands(config.subcommands); } // Create Discord client with necessary intents for member access this.client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, // Required for member fetching (privileged intent) ], }) as BotClient; // Initialize commands collection this.client.commands = new Collection(); this.setupEventHandlers(); this.loadCommands(); } private setupEventHandlers() { // Ready event this.client.once(Events.ClientReady, readyClient => { console.log(`āœ… Vynthra bot is online as ${readyClient.user.tag}`); const guilds = readyClient.guilds.cache; const guildNames = guilds.map(guild => guild.name).join(", "); console.log(`šŸ“Š Serving ${guilds.size} guilds: ${guildNames}`); }); // Guild join event - automatically register commands for new servers this.client.on(Events.GuildCreate, async guild => { console.log(`šŸ†• Bot added to new guild: ${guild.name} (${guild.id})`); try { // Deploy commands to the new guild specifically for instant availability await this.deployCommandsToGuild(guild.id); console.log(`āœ… Commands deployed to guild: ${guild.name}`); } catch (error) { console.error(`āŒ Failed to deploy commands to guild ${guild.name}:`, error); } }); // Interaction handler this.client.on(Events.InteractionCreate, async interaction => { if (!interaction.isChatInputCommand()) return; const command = this.client.commands.get(interaction.commandName); if (!command) { console.error(`āŒ No command matching ${interaction.commandName} was found.`); return; } const startTime = Date.now(); let commandExecutionSucceeded = false; let commandExecutionError: string | undefined; try { await command.execute(interaction); commandExecutionSucceeded = true; const commandDetails = this.formatCommandDetails(interaction); console.log(`āœ… Executed command: ${commandDetails}`); } catch (error) { commandExecutionSucceeded = false; commandExecutionError = error instanceof Error ? error.message : "Unknown error"; const commandDetails = this.formatCommandDetails(interaction); console.error(`āŒ Error executing command: ${commandDetails}:`, error); const errorMessage = { content: "āŒ There was an error while executing this command!", ephemeral: true, }; try { if (interaction.replied || interaction.deferred) { await interaction.followUp(errorMessage); } else { await interaction.reply(errorMessage); } } catch (followUpError) { console.error("āŒ Failed to send error message:", followUpError); } } // Emit command usage event for analytics const responseTime = Date.now() - startTime; const subcommand = interaction.options.getSubcommand(false); // Determine if response was ephemeral let isEphemeral = false; try { // Check if the interaction was deferred/replied as ephemeral if (interaction.replied || interaction.deferred) { isEphemeral = interaction.ephemeral || false; } } catch { // Default to false if we can't determine isEphemeral = false; } const usageEvent: CommandUsageEvent = { commandName: interaction.commandName, subcommand: subcommand || undefined, userTag: interaction.user.tag, userId: interaction.user.id, guildId: interaction.guild?.id, guildName: interaction.guild?.name, channelType: interaction.channel?.type.toString() || "unknown", success: commandExecutionSucceeded, errorMessage: commandExecutionError, responseTime, isEphemeral, timestamp: new Date(), }; this.eventEmitter.emit("commandUsage", usageEvent); }); // Error handlers this.client.on(Events.Error, error => { console.error("āŒ Discord client error:", error); }); this.client.on(Events.Warn, warning => { console.warn("āš ļø Discord client warning:", warning); }); // Graceful shutdown process.on("SIGINT", () => { console.log("\nšŸ›‘ Received SIGINT, shutting down gracefully..."); this.shutdown(); }); process.on("SIGTERM", () => { console.log("\nšŸ›‘ Received SIGTERM, shutting down gracefully..."); this.shutdown(); }); } private loadCommands() { // Build the main command with configurable name and subcommands const rootCommandName = this.config.rootCommandName || "zsgg"; const rootCommandDescription = this.config.rootCommandDescription || "Search ZeroSpace.gg game data"; // Collect all subcommands (built-in + user-provided) const allSubcommands: BotSubcommand[] = [...(this.config.subcommands || [])]; // Build the main slash command const commandBuilder = new SlashCommandBuilder().setName(rootCommandName).setDescription(rootCommandDescription); // Add all subcommands for (const subcommand of allSubcommands) { commandBuilder.addSubcommand(subcommand.builder); } // Create the main command handler const mainCommand: BotCommand = { data: commandBuilder, async execute(interaction) { const subcommandName = interaction.options.getSubcommand(); const subcommand = allSubcommands.find(sc => sc.name === subcommandName); if (!subcommand) { console.error(`āŒ No subcommand matching ${subcommandName} was found.`); await interaction.reply({ content: `āŒ Unknown subcommand: ${subcommandName}`, ephemeral: true, }); return; } await subcommand.execute(interaction); }, }; // Register the main command this.client.commands.set(rootCommandName, mainCommand); console.log(`šŸ“ Loaded command: ${rootCommandName} with ${allSubcommands.length} subcommands`); for (const subcommand of allSubcommands) { console.log(` └─ ${subcommand.name}: ${subcommand.description}`); } } public async start() { try { console.log("šŸš€ Starting Vynthra Discord bot..."); await this.client.login(this.config.token); } catch (error) { console.error("āŒ Failed to start bot:", error); if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test") { process.exit(1); } throw error; } } public async shutdown() { try { console.log("šŸ‘‹ Shutting down Vynthra bot..."); this.client.destroy(); // Don't exit process when used as a library - only when running standalone if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test" && require.main === module) { process.exit(0); } } catch (error) { console.error("āŒ Error during shutdown:", error); // Don't exit process when used as a library - only when running standalone if (typeof process !== "undefined" && process.env && process.env.NODE_ENV !== "test" && require.main === module) { process.exit(1); } throw error; } } /** * Format command details for logging */ private formatCommandDetails(interaction: ChatInputCommandInteraction): string { const subcommand = interaction.options.getSubcommand(false); const args = []; // Collect all options/arguments for (const option of interaction.options.data) { if (option.type === 1) { // SUB_COMMAND // For subcommands, collect their options if (option.options) { for (const subOption of option.options) { args.push(`${subOption.name}:${subOption.value}`); } } } else { // For direct command options args.push(`${option.name}:${option.value}`); } } const argsString = args.length > 0 ? ` [${args.join(", ")}]` : ""; const subcommandString = subcommand ? ` ${subcommand}` : ""; return `/${interaction.commandName}${subcommandString}${argsString} (User: ${interaction.user.tag}, Guild: ${ interaction.guild?.name || "DM" })`; } getClient(): BotClient { return this.client; } /** * Get the event emitter for listening to bot events */ getEventEmitter(): EventEmitter { return this.eventEmitter; } /** * Add a listener for command usage events */ onCommandUsage(listener: (event: CommandUsageEvent) => void): void { this.eventEmitter.on("commandUsage", listener); } /** * Remove a command usage listener */ offCommandUsage(listener: (event: CommandUsageEvent) => void): void { this.eventEmitter.off("commandUsage", listener); } public async deployCommandsToGuild(guildId: string) { try { console.log(`šŸ“ Deploying commands to guild: ${guildId}`); const guildConfig = { ...this.config, guildId: guildId, }; console.log(util.inspect(guildConfig, { depth: null, colors: true })); await deployCommands(guildConfig); console.log(`āœ… Commands successfully deployed to guild: ${guildId}`); } catch (error) { console.error(`āŒ Failed to deploy commands to guild ${guildId}:`, error); throw error; } } } export function createBot(config: BotConfig): VynthraBot { // Validate required configuration fields if (!config.token) { throw new Error("Bot configuration is missing required 'token' field"); } if (!config.clientId) { throw new Error("Bot configuration is missing required 'clientId' field"); } return new VynthraBot(config); }