From 5bc0d983347a9a6b51f6bd85942e96fffc973938 Mon Sep 17 00:00:00 2001 From: Luis Bauza Date: Tue, 25 Mar 2025 11:17:26 -0400 Subject: [PATCH] refactor: implement command class structure for better organization and error handling; update existing commands to extend from the new base class --- bot.js | 30 +++-- commands/ask.js | 255 ++++++++++++++++++----------------------- commands/help.js | 14 ++- commands/kick.js | 57 ++++----- commands/ping.js | 14 ++- commands/prune.js | 55 ++++----- logger.js | 61 +++++++++- utils/command.js | 69 +++++++++++ utils/commandLoader.js | 40 +++++-- 9 files changed, 351 insertions(+), 244 deletions(-) create mode 100644 utils/command.js diff --git a/bot.js b/bot.js index 677ca93..0f132fa 100644 --- a/bot.js +++ b/bot.js @@ -53,18 +53,24 @@ client.on(Events.InteractionCreate, async interaction => { try { await command.execute(interaction); } catch (error) { - logger.error(error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - ephemeral: true, - }); - } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - ephemeral: true, - }); - } + // Log detailed error context + logger.error( + `Command "${interaction.commandName}" failed for user ${interaction.user.tag}:`, + error, + ); + + // Prepare error response + const isProduction = process.env.NODE_ENV === 'production'; + const errorMessage = isProduction + ? `❌ Command failed. Please try again later.` + : `❌ Command failed: ${error.message}\n\n${error.stack}`; + + const responseMethod = interaction.replied || interaction.deferred ? 'followUp' : 'reply'; + + await interaction[responseMethod]({ + content: errorMessage, + ephemeral: true, + }); } }); diff --git a/commands/ask.js b/commands/ask.js index 83dfdd8..63f4e43 100644 --- a/commands/ask.js +++ b/commands/ask.js @@ -8,6 +8,7 @@ import { SlashCommandBuilder } from 'discord.js'; import axios from 'axios'; +import Command from '../utils/Command.js'; const config = { webSearch: { @@ -16,21 +17,23 @@ const config = { }, }; -export default { - data: new SlashCommandBuilder() - .setName('ask') - .setDescription('Ask a question to the AI') - .addStringOption(option => - option.setName('prompt').setDescription('Your question or prompt').setRequired(true), - ) - .addBooleanOption(option => - option - .setName('websearch') - .setDescription('Enable web search for more up-to-date information') - .setRequired(false), - ), +export default class AskCommand extends Command { + defineCommand() { + return new SlashCommandBuilder() + .setName('ask') + .setDescription('Ask a question to the AI') + .addStringOption(option => + option.setName('prompt').setDescription('Your question or prompt').setRequired(true), + ) + .addBooleanOption(option => + option + .setName('websearch') + .setDescription('Enable web search for more up-to-date information') + .setRequired(false), + ); + } - async execute(interaction) { + async run(interaction) { await interaction.deferReply(); const prompt = interaction.options.getString('prompt'); @@ -42,142 +45,110 @@ export default { ? userWebSearchOption // Use user's choice if override is allowed and option was provided : config.webSearch.enabled; // Otherwise use default config - try { - const response = await axios.post( - 'https://openrouter.ai/api/v1/chat/completions', - { - model: webSearchEnabled - ? 'google/gemma-3-27b-it:free:online' - : 'google/gemma-3-27b-it:free', - messages: [ - { - role: 'system', - content: - 'You are kekbot, a highly celebrated and knowledgeable computer scientist with decades of experience in various fields of computing. You are known for your ability to explain complex topics in a clear, concise, and insightful manner. Provide direct and to-the-point answers, avoiding unnecessary elaboration or repetition. Focus on delivering accurate and valuable information efficiently.', - }, - { role: 'user', content: prompt }, - ], - plugins: webSearchEnabled - ? [ - { - id: 'web', - max_results: 3, - search_prompt: - `A web search was conducted on ${new Date().toISOString()}. ` + - 'Incorporate the following web search results into your response. ' + - 'IMPORTANT: Cite them using markdown links named using the domain of the source. ' + - 'Example: [nytimes.com](https://nytimes.com/some-page).', - }, - ] - : undefined, - }, - { - headers: { - Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, - 'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js', - 'Content-Type': 'application/json', + const response = await axios.post( + 'https://openrouter.ai/api/v1/chat/completions', + { + model: webSearchEnabled + ? 'google/gemma-3-27b-it:free:online' + : 'google/gemma-3-27b-it:free', + messages: [ + { + role: 'system', + content: + 'You are kekbot, a highly celebrated and knowledgeable computer scientist with decades of experience in various fields of computing. You are known for your ability to explain complex topics in a clear, concise, and insightful manner. Provide direct and to-the-point answers, avoiding unnecessary elaboration or repetition. Focus on delivering accurate and valuable information efficiently.', }, + { role: 'user', content: prompt }, + ], + plugins: webSearchEnabled + ? [ + { + id: 'web', + max_results: 3, + search_prompt: + `A web search was conducted on ${new Date().toISOString()}. ` + + 'Incorporate the following web search results into your response. ' + + 'IMPORTANT: Cite them using markdown links named using the domain of the source. ' + + 'Example: [nytimes.com](https://nytimes.com/some-page).', + }, + ] + : undefined, + }, + { + headers: { + Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, + 'HTTP-Referer': 'https://github.com/hllywluis/kekbot.js', + 'Content-Type': 'application/json', }, - ); + }, + ); - const aiResponse = response.data.choices[0].message.content; - const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : ''; - const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`; + const aiResponse = response.data.choices[0].message.content; + const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : ''; + const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`; - if (formattedResponse.length <= 2000) { - // Send as a single message with proper formatting - await interaction.followUp({ - content: formattedResponse, + if (formattedResponse.length <= 2000) { + // Send as a single message with proper formatting + await interaction.followUp({ + content: formattedResponse, + split: false, + allowedMentions: { parse: [] }, + }); + } else { + // For longer messages, split while preserving markdown + const maxLength = 2000; + const chunks = []; + let remainingText = formattedResponse; + let isFirstChunk = true; + + while (remainingText.length > 0) { + let chunk = remainingText.slice(0, maxLength); + + // If we're in the middle of a code block, find a safe split point + const lastCodeBlock = chunk.lastIndexOf('```'); + if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) { + // Find the last newline before maxLength + const lastNewline = chunk.lastIndexOf('\n'); + if (lastNewline !== -1) { + chunk = chunk.slice(0, lastNewline); + } + } + + // For subsequent chunks, add continuation indicator + if (!isFirstChunk) { + chunk = `(continued)\n${chunk}`; + } + + chunks.push({ + content: chunk, split: false, allowedMentions: { parse: [] }, }); - } else { - // For longer messages, split while preserving markdown - const maxLength = 2000; - const chunks = []; - let remainingText = formattedResponse; - let isFirstChunk = true; - while (remainingText.length > 0) { - let chunk = remainingText.slice(0, maxLength); - - // If we're in the middle of a code block, find a safe split point - const lastCodeBlock = chunk.lastIndexOf('```'); - if (lastCodeBlock !== -1 && !chunk.slice(lastCodeBlock).includes('\n```')) { - // Find the last newline before maxLength - const lastNewline = chunk.lastIndexOf('\n'); - if (lastNewline !== -1) { - chunk = chunk.slice(0, lastNewline); - } - } - - // For subsequent chunks, add continuation indicator - if (!isFirstChunk) { - chunk = `(continued)\n${chunk}`; - } - - chunks.push({ - content: chunk, - split: false, - allowedMentions: { parse: [] }, - }); - - remainingText = remainingText.slice(chunk.length); - isFirstChunk = false; - } - - // Send chunks sequentially using reduce - await chunks.reduce( - (promise, chunk) => - promise.then(async () => { - try { - await interaction.followUp(chunk); - return undefined; - } catch (error) { - console.error('Error sending message chunk:', { - chunkLength: chunk.content.length, - error: error.message, - }); - // Attempt to send error notification - try { - await interaction.followUp({ - content: 'Failed to send complete response. Please try again.', - ephemeral: true, - }); - } catch (e) { - // If even the error notification fails, log it - console.error('Failed to send error notification:', e.message); - } - // Reject to stop processing remaining chunks - return Promise.reject(error); - } - }), - Promise.resolve(), - ); - } - } catch (error) { - // Log detailed error information - console.error('Error in ask command:', { - message: error.message, - response: error.response?.data, - status: error.response?.status, - stack: error.stack, - }); - - // Provide more specific error messages to users - let errorMessage = 'Sorry, there was an error processing your request.'; - if (error.response?.status === 429) { - errorMessage = 'The AI service is currently busy. Please try again in a few moments.'; - } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { - errorMessage = 'The request timed out. Please try again.'; - } else if (error.response?.status === 400) { - errorMessage = 'Invalid request. Please try rephrasing your question.'; + remainingText = remainingText.slice(chunk.length); + isFirstChunk = false; } - await interaction.followUp({ - content: errorMessage, - ephemeral: true, - }); + // Send chunks sequentially using reduce + await chunks.reduce( + (promise, chunk) => + promise.then(async () => { + await interaction.followUp(chunk); + }), + Promise.resolve(), + ); } - }, -}; + } + + getErrorMessage(error) { + if (error.response?.status === 429) { + return 'The AI service is currently busy. Please try again in a few moments.'; + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + return 'The request timed out. Please try again.'; + } + if (error.response?.status === 400) { + return 'Invalid request. Please try rephrasing your question.'; + } + return 'Sorry, there was an error processing your request.'; + } +} diff --git a/commands/help.js b/commands/help.js index b3adcb1..9cf5712 100644 --- a/commands/help.js +++ b/commands/help.js @@ -7,10 +7,14 @@ // (at your option) any later version. import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import Command from '../utils/Command.js'; -export default { - data: new SlashCommandBuilder().setName('help').setDescription('Lists all available commands'), - async execute(interaction) { +export default class HelpCommand extends Command { + defineCommand() { + return new SlashCommandBuilder().setName('help').setDescription('Lists all available commands'); + } + + async run(interaction) { const { commands } = interaction.client; const helpEmbed = new EmbedBuilder() .setColor('#5dc67b') @@ -26,5 +30,5 @@ export default { }); await interaction.reply({ embeds: [helpEmbed], ephemeral: true }); - }, -}; + } +} diff --git a/commands/kick.js b/commands/kick.js index b069ba2..0edfc7c 100644 --- a/commands/kick.js +++ b/commands/kick.js @@ -7,49 +7,36 @@ // (at your option) any later version. import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import Command from '../utils/Command.js'; -// eslint-disable-next-line import/extensions -import logger from '../logger.js'; +export default class KickCommand extends Command { + defineCommand() { + return new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a user from the server') + .addUserOption(option => + option.setName('target').setDescription('The user to kick').setRequired(true), + ) + .addStringOption(option => option.setName('reason').setDescription('Reason for kicking')) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers); + } -export default { - data: new SlashCommandBuilder() - .setName('kick') - .setDescription('Kick a user from the server') - .addUserOption(option => - option.setName('target').setDescription('The user to kick').setRequired(true), - ) - .addStringOption(option => option.setName('reason').setDescription('Reason for kicking')) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - async execute(interaction) { + async run(interaction) { const target = interaction.options.getMember('target'); const reason = interaction.options.getString('reason') ?? 'No reason provided'; if (!target) { - return interaction.reply({ - content: 'That user is not in this server!', - ephemeral: true, - }); + throw new Error('That user is not in this server!'); } if (!target.kickable) { - return interaction.reply({ - content: 'I cannot kick this user! They may have higher permissions than me.', - ephemeral: true, - }); + throw new Error('I cannot kick this user! They may have higher permissions than me.'); } - try { - await target.kick(reason); - return await interaction.reply({ - content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`, - ephemeral: true, - }); - } catch (error) { - logger.error(error); - return interaction.reply({ - content: 'There was an error trying to kick this user!', - ephemeral: true, - }); - } - }, -}; + await target.kick(reason); + await interaction.reply({ + content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`, + ephemeral: true, + }); + } +} diff --git a/commands/ping.js b/commands/ping.js index 9208bb5..d3a915c 100644 --- a/commands/ping.js +++ b/commands/ping.js @@ -7,10 +7,14 @@ // (at your option) any later version. import { SlashCommandBuilder } from 'discord.js'; +import Command from '../utils/Command.js'; -export default { - data: new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!'), - async execute(interaction) { +export default class PingCommand extends Command { + defineCommand() { + return new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!'); + } + + async run(interaction) { await interaction.reply('Pong! 🏓'); - }, -}; + } +} diff --git a/commands/prune.js b/commands/prune.js index f69ec5b..bd87b3f 100644 --- a/commands/prune.js +++ b/commands/prune.js @@ -7,35 +7,30 @@ // (at your option) any later version. import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import Command from '../utils/Command.js'; -export default { - data: new SlashCommandBuilder() - .setName('prune') - .setDescription('Prune up to 99 messages.') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Number of messages to prune') - .setMinValue(1) - .setMaxValue(99) - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - async execute(interaction) { +export default class PruneCommand extends Command { + defineCommand() { + return new SlashCommandBuilder() + .setName('prune') + .setDescription('Prune up to 99 messages.') + .addIntegerOption(option => + option + .setName('amount') + .setDescription('Number of messages to prune') + .setMinValue(1) + .setMaxValue(99) + .setRequired(true), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages); + } + + async run(interaction) { const amount = interaction.options.getInteger('amount'); - - try { - const deleted = await interaction.channel.bulkDelete(amount, true); - await interaction.reply({ - content: `Successfully deleted ${deleted.size} message(s).`, - ephemeral: true, - }); - } catch (error) { - console.error(error); - await interaction.reply({ - content: 'There was an error trying to prune messages in this channel!', - ephemeral: true, - }); - } - }, -}; + const deleted = await interaction.channel.bulkDelete(amount, true); + await interaction.reply({ + content: `Successfully deleted ${deleted.size} message(s).`, + ephemeral: true, + }); + } +} diff --git a/logger.js b/logger.js index 7d2468b..8b2be48 100644 --- a/logger.js +++ b/logger.js @@ -1,28 +1,79 @@ import chalk from 'chalk'; +/** + * Enhanced logger utility with timestamps and consistent formatting + */ class Logger { + /** + * Create a new Logger instance + * @param {string} moduleName - Name of the module for context + */ constructor(moduleName) { + if (typeof moduleName !== 'string') { + throw new Error('Logger requires a string moduleName'); + } this.moduleName = moduleName; } + /** + * Get formatted timestamp + * @private + */ + #getTimestamp() { + return new Date().toISOString(); + } + + /** + * Format log message with consistent structure + * @private + * @param {string} emoji - Log level emoji + * @param {string} level - Log level name + * @param {string} message - Message to log + */ + #formatMessage(emoji, level, message) { + const timestamp = this.#getTimestamp(); + return `${chalk.gray(timestamp)} ${emoji} ${chalk.cyan(`[${this.moduleName}]`)} ${level}: ${message}`; + } + + /** + * Log informational message + * @param {string} message - Message to log + */ log(message) { - console.log(`${chalk.blue('📝')} ${chalk.blue(`[${this.moduleName}]`)} ${message}`); + console.log(this.#formatMessage(chalk.blue('📝'), 'LOG', message)); } + /** + * Log error message + * @param {string|Error} message - Error message or Error object + */ error(message) { - console.error(`${chalk.red('❌')} ${chalk.red(`[${this.moduleName}]`)} ${message}`); + const msg = message instanceof Error ? message.stack || message.message : message; + console.error(this.#formatMessage(chalk.red('❌'), 'ERROR', msg)); } + /** + * Log warning message + * @param {string} message - Warning message + */ warn(message) { - console.warn(`${chalk.yellow('âš ī¸')} ${chalk.yellow(`[${this.moduleName}]`)} ${message}`); + console.warn(this.#formatMessage(chalk.yellow('âš ī¸'), 'WARN', message)); } + /** + * Log info message + * @param {string} message - Info message + */ info(message) { - console.info(`${chalk.green('â„šī¸')} ${chalk.green(`[${this.moduleName}]`)} ${message}`); + console.info(this.#formatMessage(chalk.green('â„šī¸'), 'INFO', message)); } + /** + * Log debug message + * @param {string} message - Debug message + */ debug(message) { - console.debug(`${chalk.gray('🔧')} ${chalk.gray(`[${this.moduleName}]`)} ${message}`); + console.debug(this.#formatMessage(chalk.gray('🔧'), 'DEBUG', message)); } } diff --git a/utils/command.js b/utils/command.js new file mode 100644 index 0000000..5ac7b3b --- /dev/null +++ b/utils/command.js @@ -0,0 +1,69 @@ +// utils/Command.js - Base command class for Discord bot commands +// Copyright (C) 2025 Luis Bauza +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +import { SlashCommandBuilder } from 'discord.js'; +import logger from '../logger.js'; + +export default class Command { + constructor() { + if (this.constructor === Command) { + throw new Error('Abstract class Command cannot be instantiated'); + } + + this._data = this.defineCommand(); + this._logger = new logger(this.constructor.name); + } + + get data() { + return this._data; + } + + // Abstract method - must be implemented by subclasses + defineCommand() { + throw new Error('Method defineCommand() must be implemented'); + } + + // Common execute method with standardized error handling + async execute(interaction) { + try { + this._logger.info(`Executing command: ${interaction.commandName}`); + await this.run(interaction); + } catch (error) { + this.handleError(interaction, error); + } + } + + // Abstract method - must be implemented by subclasses + async run(interaction) { + throw new Error('Method run() must be implemented'); + } + + // Standardized error handling + handleError(interaction, error) { + this._logger.error(`Command ${interaction.commandName} failed:`, error); + + const response = interaction.deferred ? 'followUp' : 'reply'; + const errorMessage = this.getErrorMessage(error); + + interaction[response]({ + content: errorMessage, + ephemeral: true, + }); + } + + // Customizable error message handling + getErrorMessage(error) { + if (error.response?.status === 429) { + return 'The service is currently busy. Please try again later.'; + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + return 'The request timed out. Please try again.'; + } + return `Command failed: ${error.message}`; + } +} diff --git a/utils/commandLoader.js b/utils/commandLoader.js index 9cd7006..534419c 100644 --- a/utils/commandLoader.js +++ b/utils/commandLoader.js @@ -1,6 +1,19 @@ import { readdirSync } from 'node:fs'; import { join } from 'node:path'; +/** + * Asynchronously loads all command modules from a directory + * @async + * @param {string} commandsPath - Path to the commands directory + * @param {Logger} logger - Logger instance for logging loading progress + * @returns {Promise>} Array of command objects with: + * @property {SlashCommandBuilder} data - Command definition + * @property {Function} execute - Command execution function + * @throws {TypeError} If commandsPath is not a string + * @example + * // Load all commands from './commands' + * const commands = await loadCommands('./commands', logger); + */ export async function loadCommands(commandsPath, logger) { const commands = []; const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js')); @@ -10,21 +23,28 @@ export async function loadCommands(commandsPath, logger) { try { const filePath = join(commandsPath, file); const commandModule = await import(filePath); - const command = commandModule.default; + const Command = commandModule.default; - if (!command?.data || !command?.execute) { - logger.warn( - `[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.` - ); + const CommandBase = (await import('./Command.js')).default; + if (Command.prototype instanceof CommandBase) { + // New style - class extending Command + const commandInstance = new Command(); + // Wrap run() in execute() for backward compatibility + commandInstance.execute = commandInstance.run.bind(commandInstance); + commands.push(commandInstance); + logger.log(`Loaded command: ${commandInstance.data.name}`); + } else if (Command?.data && Command?.execute) { + // Old style - plain object (maintain backward compatibility) + commands.push(Command); + logger.log(`Loaded command: ${Command.data.name}`); + } else { + logger.warn(`[WARNING] The command at ${filePath} is missing required properties.`); return; } - - commands.push(command); - logger.log(`Loaded command: ${command.data.name}`); } catch (error) { - logger.error(`Error loading command from ${file}:`, error); + logger.error(`Error loading command from ${file}: ${error.stack}`); } - }) + }), ); return commands;