refactor: implement command class structure for better organization and error handling; update existing commands to extend from the new base class

This commit is contained in:
2025-03-25 11:17:26 -04:00
parent 69dc616668
commit 5bc0d98334
9 changed files with 351 additions and 244 deletions
+18 -12
View File
@@ -53,18 +53,24 @@ client.on(Events.InteractionCreate, async interaction => {
try { try {
await command.execute(interaction); await command.execute(interaction);
} catch (error) { } catch (error) {
logger.error(error); // Log detailed error context
if (interaction.replied || interaction.deferred) { logger.error(
await interaction.followUp({ `Command "${interaction.commandName}" failed for user ${interaction.user.tag}:`,
content: 'There was an error while executing this command!', error,
ephemeral: true, );
});
} else { // Prepare error response
await interaction.reply({ const isProduction = process.env.NODE_ENV === 'production';
content: 'There was an error while executing this command!', const errorMessage = isProduction
ephemeral: true, ? `❌ 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,
});
} }
}); });
+113 -142
View File
@@ -8,6 +8,7 @@
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import axios from 'axios'; import axios from 'axios';
import Command from '../utils/Command.js';
const config = { const config = {
webSearch: { webSearch: {
@@ -16,21 +17,23 @@ const config = {
}, },
}; };
export default { export default class AskCommand extends Command {
data: new SlashCommandBuilder() defineCommand() {
.setName('ask') return new SlashCommandBuilder()
.setDescription('Ask a question to the AI') .setName('ask')
.addStringOption(option => .setDescription('Ask a question to the AI')
option.setName('prompt').setDescription('Your question or prompt').setRequired(true), .addStringOption(option =>
) option.setName('prompt').setDescription('Your question or prompt').setRequired(true),
.addBooleanOption(option => )
option .addBooleanOption(option =>
.setName('websearch') option
.setDescription('Enable web search for more up-to-date information') .setName('websearch')
.setRequired(false), .setDescription('Enable web search for more up-to-date information')
), .setRequired(false),
);
}
async execute(interaction) { async run(interaction) {
await interaction.deferReply(); await interaction.deferReply();
const prompt = interaction.options.getString('prompt'); 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 ? userWebSearchOption // Use user's choice if override is allowed and option was provided
: config.webSearch.enabled; // Otherwise use default config : config.webSearch.enabled; // Otherwise use default config
try { const response = await axios.post(
const response = await axios.post( 'https://openrouter.ai/api/v1/chat/completions',
'https://openrouter.ai/api/v1/chat/completions', {
{ model: webSearchEnabled
model: webSearchEnabled ? 'google/gemma-3-27b-it:free:online'
? 'google/gemma-3-27b-it:free:online' : 'google/gemma-3-27b-it:free',
: 'google/gemma-3-27b-it:free', messages: [
messages: [ {
{ role: 'system',
role: 'system', content:
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.',
'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',
}, },
{ 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 aiResponse = response.data.choices[0].message.content;
const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : ''; const webSearchStatus = webSearchEnabled ? '\n> *Web search enabled* 🔍\n' : '';
const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`; const formattedResponse = `> **Question:** ${prompt}${webSearchStatus}\n${aiResponse}`;
if (formattedResponse.length <= 2000) { if (formattedResponse.length <= 2000) {
// Send as a single message with proper formatting // Send as a single message with proper formatting
await interaction.followUp({ await interaction.followUp({
content: formattedResponse, 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, split: false,
allowedMentions: { parse: [] }, 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) { remainingText = remainingText.slice(chunk.length);
let chunk = remainingText.slice(0, maxLength); isFirstChunk = false;
// 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.';
} }
await interaction.followUp({ // Send chunks sequentially using reduce
content: errorMessage, await chunks.reduce(
ephemeral: true, (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.';
}
}
+9 -5
View File
@@ -7,10 +7,14 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; import { SlashCommandBuilder, EmbedBuilder } from 'discord.js';
import Command from '../utils/Command.js';
export default { export default class HelpCommand extends Command {
data: new SlashCommandBuilder().setName('help').setDescription('Lists all available commands'), defineCommand() {
async execute(interaction) { return new SlashCommandBuilder().setName('help').setDescription('Lists all available commands');
}
async run(interaction) {
const { commands } = interaction.client; const { commands } = interaction.client;
const helpEmbed = new EmbedBuilder() const helpEmbed = new EmbedBuilder()
.setColor('#5dc67b') .setColor('#5dc67b')
@@ -26,5 +30,5 @@ export default {
}); });
await interaction.reply({ embeds: [helpEmbed], ephemeral: true }); await interaction.reply({ embeds: [helpEmbed], ephemeral: true });
}, }
}; }
+22 -35
View File
@@ -7,49 +7,36 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import Command from '../utils/Command.js';
// eslint-disable-next-line import/extensions export default class KickCommand extends Command {
import logger from '../logger.js'; 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 { async run(interaction) {
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) {
const target = interaction.options.getMember('target'); const target = interaction.options.getMember('target');
const reason = interaction.options.getString('reason') ?? 'No reason provided'; const reason = interaction.options.getString('reason') ?? 'No reason provided';
if (!target) { if (!target) {
return interaction.reply({ throw new Error('That user is not in this server!');
content: 'That user is not in this server!',
ephemeral: true,
});
} }
if (!target.kickable) { if (!target.kickable) {
return interaction.reply({ throw new Error('I cannot kick this user! They may have higher permissions than me.');
content: 'I cannot kick this user! They may have higher permissions than me.',
ephemeral: true,
});
} }
try { await target.kick(reason);
await target.kick(reason); await interaction.reply({
return await interaction.reply({ content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`,
content: `Successfully kicked ${target.user.tag}\nReason: ${reason}`, ephemeral: true,
ephemeral: true, });
}); }
} catch (error) { }
logger.error(error);
return interaction.reply({
content: 'There was an error trying to kick this user!',
ephemeral: true,
});
}
},
};
+9 -5
View File
@@ -7,10 +7,14 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
import Command from '../utils/Command.js';
export default { export default class PingCommand extends Command {
data: new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!'), defineCommand() {
async execute(interaction) { return new SlashCommandBuilder().setName('ping').setDescription('Replies with Pong!');
}
async run(interaction) {
await interaction.reply('Pong! 🏓'); await interaction.reply('Pong! 🏓');
}, }
}; }
+25 -30
View File
@@ -7,35 +7,30 @@
// (at your option) any later version. // (at your option) any later version.
import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js';
import Command from '../utils/Command.js';
export default { export default class PruneCommand extends Command {
data: new SlashCommandBuilder() defineCommand() {
.setName('prune') return new SlashCommandBuilder()
.setDescription('Prune up to 99 messages.') .setName('prune')
.addIntegerOption(option => .setDescription('Prune up to 99 messages.')
option .addIntegerOption(option =>
.setName('amount') option
.setDescription('Number of messages to prune') .setName('amount')
.setMinValue(1) .setDescription('Number of messages to prune')
.setMaxValue(99) .setMinValue(1)
.setRequired(true), .setMaxValue(99)
) .setRequired(true),
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), )
async execute(interaction) { .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages);
}
async run(interaction) {
const amount = interaction.options.getInteger('amount'); const amount = interaction.options.getInteger('amount');
const deleted = await interaction.channel.bulkDelete(amount, true);
try { await interaction.reply({
const deleted = await interaction.channel.bulkDelete(amount, true); content: `Successfully deleted ${deleted.size} message(s).`,
await interaction.reply({ ephemeral: true,
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,
});
}
},
};
+56 -5
View File
@@ -1,28 +1,79 @@
import chalk from 'chalk'; import chalk from 'chalk';
/**
* Enhanced logger utility with timestamps and consistent formatting
*/
class Logger { class Logger {
/**
* Create a new Logger instance
* @param {string} moduleName - Name of the module for context
*/
constructor(moduleName) { constructor(moduleName) {
if (typeof moduleName !== 'string') {
throw new Error('Logger requires a string moduleName');
}
this.moduleName = 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) { 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) { 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) { 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) { 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) { debug(message) {
console.debug(`${chalk.gray('🔧')} ${chalk.gray(`[${this.moduleName}]`)} ${message}`); console.debug(this.#formatMessage(chalk.gray('🔧'), 'DEBUG', message));
} }
} }
+69
View File
@@ -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}`;
}
}
+30 -10
View File
@@ -1,6 +1,19 @@
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { join } from 'node:path'; 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<Object>>} 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) { export async function loadCommands(commandsPath, logger) {
const commands = []; const commands = [];
const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js')); const commandFiles = readdirSync(commandsPath).filter(file => file.endsWith('.js'));
@@ -10,21 +23,28 @@ export async function loadCommands(commandsPath, logger) {
try { try {
const filePath = join(commandsPath, file); const filePath = join(commandsPath, file);
const commandModule = await import(filePath); const commandModule = await import(filePath);
const command = commandModule.default; const Command = commandModule.default;
if (!command?.data || !command?.execute) { const CommandBase = (await import('./Command.js')).default;
logger.warn( if (Command.prototype instanceof CommandBase) {
`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.` // 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; return;
} }
commands.push(command);
logger.log(`Loaded command: ${command.data.name}`);
} catch (error) { } catch (error) {
logger.error(`Error loading command from ${file}:`, error); logger.error(`Error loading command from ${file}: ${error.stack}`);
} }
}) }),
); );
return commands; return commands;