Modernize Discord bot to v14 and Node.js 22

Major upgrades and architectural improvements:
- Upgrade Discord.js from v12 to v14.21.0
- Upgrade Node.js from 14 to 22 LTS
- Switch to pnpm package manager
- Complete rewrite with modern Discord API patterns

New Features:
- Hybrid command system: prefix commands + slash commands
- /sfx slash command with autocomplete for sound discovery
- Modern @discordjs/voice integration for audio
- Improved voice connection management
- Enhanced logging for SFX commands
- Multi-stage Docker build for optimized images

Technical Improvements:
- Modular architecture with services and command handlers
- Proper intent management for Discord gateway
- Better error handling and logging
- Hot-reload capability maintained
- Environment variable support
- Optimized Docker container with Alpine Linux

Breaking Changes:
- Moved main entry from index.js to src/index.js
- Updated configuration structure for v14 compatibility
- Replaced deprecated voice APIs with @discordjs/voice
- Updated audio dependencies (opus, ffmpeg)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Ham
2025-08-16 11:37:37 -07:00
parent 19c8f4fa85
commit 0ad4265bed
31 changed files with 2931 additions and 381 deletions

View File

@@ -0,0 +1,10 @@
module.exports = {
name: 'dance',
description: 'Make the bot dance!',
async execute(message, args, guildConfig) {
await message.channel.send(
'*┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛*'
);
}
};

View File

@@ -0,0 +1,79 @@
const { EmbedBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
class FunFactCommand {
constructor() {
this.funFactsPath = path.join(__dirname, '..', '..', '..', 'conf', 'funfacts');
this.funFacts = [];
this.loadFunFacts();
this.watchFile();
}
loadFunFacts() {
try {
if (!fs.existsSync(this.funFactsPath)) {
console.log('Fun facts file not found');
return;
}
const data = fs.readFileSync(this.funFactsPath, 'utf-8');
this.funFacts = data.split('\n').filter(line => line.trim().length > 0);
console.log(`Loaded ${this.funFacts.length} fun facts`);
} catch (error) {
console.error('Error loading fun facts:', error);
}
}
watchFile() {
if (fs.existsSync(this.funFactsPath)) {
fs.watchFile(this.funFactsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Fun facts file changed, reloading...');
this.loadFunFacts();
}
});
}
}
async execute(message, args, guildConfig) {
if (guildConfig.enableFunFacts === false) {
return;
}
if (this.funFacts.length === 0) {
return message.channel.send('No fun facts found!');
}
// Check if a specific fact number was requested
let factIndex;
const requestedNum = parseInt(args[0]);
if (!isNaN(requestedNum) && requestedNum > 0 && requestedNum <= this.funFacts.length) {
factIndex = requestedNum - 1;
} else {
factIndex = Math.floor(Math.random() * this.funFacts.length);
}
const displayNum = factIndex + 1;
const funFact = this.funFacts[factIndex];
const embed = new EmbedBuilder()
.setTitle(`FunFact #${displayNum}`)
.setColor(0x21c629)
.setDescription(funFact);
await message.channel.send({ embeds: [embed] });
}
}
const funFactCommand = new FunFactCommand();
module.exports = {
name: 'funfact',
description: 'Get a random fun fact',
async execute(message, args, guildConfig) {
await funFactCommand.execute(message, args, guildConfig);
}
};

View File

@@ -0,0 +1,79 @@
const { EmbedBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
class HamFactCommand {
constructor() {
this.hamFactsPath = path.join(__dirname, '..', '..', '..', 'conf', 'hamfacts');
this.hamFacts = [];
this.loadHamFacts();
this.watchFile();
}
loadHamFacts() {
try {
if (!fs.existsSync(this.hamFactsPath)) {
console.log('Ham facts file not found');
return;
}
const data = fs.readFileSync(this.hamFactsPath, 'utf-8');
this.hamFacts = data.split('\n').filter(line => line.trim().length > 0);
console.log(`Loaded ${this.hamFacts.length} ham facts`);
} catch (error) {
console.error('Error loading ham facts:', error);
}
}
watchFile() {
if (fs.existsSync(this.hamFactsPath)) {
fs.watchFile(this.hamFactsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Ham facts file changed, reloading...');
this.loadHamFacts();
}
});
}
}
async execute(message, args, guildConfig) {
if (guildConfig.enableHamFacts === false) {
return;
}
if (this.hamFacts.length === 0) {
return message.channel.send('No ham facts found!');
}
// Check if a specific fact number was requested
let factIndex;
const requestedNum = parseInt(args[0]);
if (!isNaN(requestedNum) && requestedNum > 0 && requestedNum <= this.hamFacts.length) {
factIndex = requestedNum - 1;
} else {
factIndex = Math.floor(Math.random() * this.hamFacts.length);
}
const displayNum = factIndex + 1;
const hamFact = this.hamFacts[factIndex];
const embed = new EmbedBuilder()
.setTitle(`HamFact #${displayNum}`)
.setColor(0x21c629)
.setDescription(hamFact);
await message.channel.send({ embeds: [embed] });
}
}
const hamFactCommand = new HamFactCommand();
module.exports = {
name: 'hamfact',
description: 'Get a random ham fact',
async execute(message, args, guildConfig) {
await hamFactCommand.execute(message, args, guildConfig);
}
};

View File

@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
class PrefixCommandHandler {
constructor() {
this.commands = new Map();
this.loadCommands();
}
/**
* Load all prefix command modules
*/
loadCommands() {
const commandFiles = fs.readdirSync(__dirname)
.filter(file => file.endsWith('.js') && file !== 'index.js');
for (const file of commandFiles) {
const command = require(path.join(__dirname, file));
// Register command and any aliases
if (command.name) {
this.commands.set(command.name, command);
if (command.aliases && Array.isArray(command.aliases)) {
for (const alias of command.aliases) {
this.commands.set(alias, command);
}
}
}
}
console.log(`Loaded ${this.commands.size} prefix commands`);
}
/**
* Check if a command exists
* @param {string} commandName
* @returns {boolean}
*/
has(commandName) {
return this.commands.has(commandName);
}
/**
* Execute a command
* @param {string} commandName
* @param {Message} message
* @param {Array} args
* @param {Object} guildConfig
*/
async execute(commandName, message, args, guildConfig) {
const command = this.commands.get(commandName);
if (!command) {
return;
}
try {
await command.execute(message, args, guildConfig);
} catch (error) {
console.error(`Error executing prefix command ${commandName}:`, error);
throw error;
}
}
}
module.exports = new PrefixCommandHandler();

View File

@@ -0,0 +1,26 @@
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'join',
description: 'Make the bot join your voice channel',
async execute(message, args, guildConfig) {
// Check if user is in a voice channel
if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel first!');
}
// Check if already connected
if (voiceService.isConnected(message.guild.id)) {
return message.reply("I'm already in a voice channel!");
}
try {
await voiceService.join(message.member.voice.channel);
await message.react('✅');
} catch (error) {
console.error('Error joining voice channel:', error);
await message.reply("I couldn't connect to your voice channel. Make sure I have the proper permissions!");
}
}
};

View File

@@ -0,0 +1,16 @@
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'leave',
description: 'Make the bot leave the voice channel',
async execute(message, args, guildConfig) {
// Check if connected to a voice channel
if (!voiceService.isConnected(message.guild.id)) {
return message.reply("If ya don't eat your meat, ya can't have any pudding!");
}
voiceService.leave(message.guild.id);
await message.react('👋');
}
};

View File

@@ -0,0 +1,19 @@
const config = require("../../config/config");
module.exports = {
name: "reboot",
description: "Reboot the bot (admin only)",
async execute(message, args, guildConfig) {
// Check if user is the bot admin
if (message.author.id !== config.discord.adminUserId) {
return;
}
await message.reply("Rebooting...");
console.log(`Reboot requested by ${message.author.username}`);
// Exit the process - requires a process manager like PM2 or Docker restart policy
process.exit(0);
},
};

View File

@@ -0,0 +1,74 @@
module.exports = {
name: 'role',
description: 'Add or remove allowed roles',
async execute(message, args, guildConfig) {
// Check if there are allowed roles configured
if (!guildConfig.allowedRolesForRequest || guildConfig.allowedRolesForRequest.length === 0) {
return message.reply('No roles are currently allowed to be added/removed by members.');
}
// Show usage if no arguments
if (args.length === 0) {
return message.reply(
`Usage: ${guildConfig.prefix}role {add|remove} {${guildConfig.allowedRolesForRequest}}`
);
}
const action = args[0]?.toLowerCase();
const roleName = args.slice(1).join(' ');
// Validate action
if (!['add', 'remove'].includes(action)) {
return message.reply(
`You must use add/remove after the role command! *e.g. ${guildConfig.prefix}role add <rolename>*`
);
}
// Validate role name
if (!roleName) {
return message.reply(
`Usage: ${guildConfig.prefix}role {add|remove} {${guildConfig.allowedRolesForRequest}}`
);
}
// Check if role is in the allowed list
const allowedRoles = guildConfig.allowedRolesForRequest.split('|');
const roleRegex = new RegExp(guildConfig.allowedRolesForRequest, 'i');
if (!roleRegex.test(roleName)) {
return message.reply(
`**${roleName}** is not a valid role name! The roles allowed for request are: ${allowedRoles.join(', ')}`
);
}
// Find the role in the guild (case-sensitive search)
const role = message.guild.roles.cache.find(r =>
r.name.toLowerCase() === roleName.toLowerCase()
);
if (!role) {
return message.reply(`${roleName} is not a role on this server!`);
}
try {
if (action === 'add') {
await message.member.roles.add(role, 'User requested');
await message.react('👍');
console.log(`Added role ${role.name} to ${message.author.username}`);
} else if (action === 'remove') {
await message.member.roles.remove(role, 'User requested');
await message.react('👍');
console.log(`Removed role ${role.name} from ${message.author.username}`);
}
} catch (error) {
console.error(`Error managing role ${role.name}:`, error);
await message.react('⚠️');
// Send error message if we can't react
if (!message.reactions.cache.has('⚠️')) {
await message.reply('I encountered an error managing that role. Make sure I have the proper permissions!');
}
}
}
};

View File

@@ -0,0 +1,87 @@
const axios = require('axios');
const { chunkSubstr } = require('../../utils/helpers');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'sfx',
description: 'Play a sound effect',
async execute(message, args, guildConfig) {
// Check if SFX is allowed in this channel
if (guildConfig.allowedSfxChannels) {
const allowedChannels = new RegExp(guildConfig.allowedSfxChannels);
if (!allowedChannels.test(message.channel.name)) {
return;
}
}
const sfxName = args[0];
// Log the SFX command
if (sfxName) {
console.log(
`SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}`
);
}
// If no SFX specified, show the list
if (!sfxName) {
try {
const response = await axios.get('https://rentry.co/ghbotsfx/raw');
// Break into chunks if message is too long
let chunks = [response.data];
if (response.data.length > 2000) {
chunks = chunkSubstr(response.data, Math.ceil(response.data.length / 2));
}
for (const chunk of chunks) {
await message.channel.send(chunk);
}
} catch (error) {
console.error('Error fetching SFX list:', error);
await message.reply('Could not fetch the SFX list.');
}
return;
}
// Check if SFX exists
if (!sfxManager.hasSFX(sfxName)) {
return message.reply('This sound effect does not exist!');
}
// Check if user is in a voice channel
if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel to use this command!');
}
try {
// Join the voice channel
await voiceService.join(message.member.voice.channel);
// Get the SFX file path
const sfxPath = sfxManager.getSFXPath(sfxName);
// Play the sound effect
await voiceService.play(
message.guild.id,
sfxPath,
{
volume: guildConfig.sfxVolume || 0.5
}
);
// Leave the voice channel after playing
setTimeout(() => {
voiceService.leave(message.guild.id);
}, 500);
console.log(`✅ Successfully played SFX '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing SFX '${sfxName}':`, error);
await message.reply("I couldn't play that sound effect. Make sure I have permission to join your voice channel!");
}
}
};

View File

@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
class SlashCommandHandler {
constructor() {
this.commands = new Map();
this.loadCommands();
}
/**
* Load all slash command modules
*/
loadCommands() {
const commandFiles = fs.readdirSync(__dirname)
.filter(file => file.endsWith('.js') && file !== 'index.js');
for (const file of commandFiles) {
const command = require(path.join(__dirname, file));
if (command.data?.name) {
this.commands.set(command.data.name, command);
}
}
console.log(`Loaded ${this.commands.size} slash commands`);
}
/**
* Get slash command definitions for registration
* @returns {Array}
*/
getSlashCommandDefinitions() {
return Array.from(this.commands.values()).map(cmd => cmd.data.toJSON());
}
/**
* Execute a slash command
* @param {string} commandName
* @param {CommandInteraction} interaction
* @param {Object} guildConfig
*/
async execute(commandName, interaction, guildConfig) {
const command = this.commands.get(commandName);
if (!command) {
return;
}
try {
await command.execute(interaction, guildConfig);
} catch (error) {
console.error(`Error executing slash command ${commandName}:`, error);
throw error;
}
}
/**
* Handle autocomplete interactions
* @param {AutocompleteInteraction} interaction
* @param {Object} guildConfig
*/
async handleAutocomplete(interaction, guildConfig) {
const command = this.commands.get(interaction.commandName);
if (!command || !command.autocomplete) {
return;
}
try {
await command.autocomplete(interaction, guildConfig);
} catch (error) {
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
}
}
}
module.exports = new SlashCommandHandler();

108
src/commands/slash/sfx.js Normal file
View File

@@ -0,0 +1,108 @@
const { SlashCommandBuilder } = require('discord.js');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
module.exports = {
data: new SlashCommandBuilder()
.setName('sfx')
.setDescription('Play a sound effect')
.addStringOption(option =>
option.setName('sound')
.setDescription('The sound effect to play')
.setRequired(true)
.setAutocomplete(true)
),
async execute(interaction, guildConfig) {
// Check if SFX is allowed in this channel
if (guildConfig.allowedSfxChannels) {
const allowedChannels = new RegExp(guildConfig.allowedSfxChannels);
if (!allowedChannels.test(interaction.channel.name)) {
return interaction.reply({
content: 'Sound effects are not allowed in this channel!',
ephemeral: true
});
}
}
const sfxName = interaction.options.getString('sound');
// Log the slash command SFX request
console.log(
`/sfx '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}`
);
// Check if SFX exists
if (!sfxManager.hasSFX(sfxName)) {
return interaction.reply({
content: 'This sound effect does not exist!',
ephemeral: true
});
}
// Check if user is in a voice channel
const member = interaction.member;
if (!member.voice.channel) {
return interaction.reply({
content: 'You need to be in a voice channel to use this command!',
ephemeral: true
});
}
// Defer the reply as joining voice might take a moment
await interaction.deferReply();
try {
// Join the voice channel
await voiceService.join(member.voice.channel);
// Get the SFX file path
const sfxPath = sfxManager.getSFXPath(sfxName);
// Play the sound effect
await voiceService.play(
interaction.guild.id,
sfxPath,
{
volume: guildConfig.sfxVolume || 0.5
}
);
// Update the reply
await interaction.editReply(`Playing sound effect: **${sfxName}**`);
// Leave the voice channel after playing
setTimeout(() => {
voiceService.leave(interaction.guild.id);
}, 500);
console.log(`✅ Successfully played /sfx '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing /sfx '${sfxName}':`, error);
await interaction.editReply({
content: "I couldn't play that sound effect. Make sure I have permission to join your voice channel!"
});
}
},
async autocomplete(interaction, guildConfig) {
const focusedValue = interaction.options.getFocused().toLowerCase();
// Get all SFX names
const choices = sfxManager.getSFXNames();
// Filter based on what the user has typed
const filtered = choices
.filter(choice => choice.toLowerCase().includes(focusedValue))
.slice(0, 25); // Discord limits autocomplete to 25 choices
// Respond with the filtered choices
await interaction.respond(
filtered.map(choice => ({
name: choice,
value: choice
}))
);
}
};