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
}))
);
}
};

26
src/config/config.js Normal file
View File

@@ -0,0 +1,26 @@
const fs = require("fs");
const path = require("path");
// Load config from root directory
const configPath = path.join(__dirname, "..", "..", "config.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
// Validate required config fields
function validateConfig(config) {
if (!config.discord?.token) {
throw new Error("Discord token is required in config.json");
}
if (!config.discord?.guilds || !Array.isArray(config.discord.guilds)) {
throw new Error("Discord guilds configuration is required");
}
// Ensure guilds is an array (supporting both old object format and new array format)
if (!Array.isArray(config.discord.guilds)) {
config.discord.guilds = Object.values(config.discord.guilds);
}
return config;
}
module.exports = validateConfig(config);

10
src/config/intents.js Normal file
View File

@@ -0,0 +1,10 @@
const { GatewayIntentBits } = require('discord.js');
module.exports = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent, // Required for prefix commands
GatewayIntentBits.GuildMembers // Required for role management
// GatewayIntentBits.GuildPresences - Requires special permission in Discord Developer Portal
];

253
src/index.js Normal file
View File

@@ -0,0 +1,253 @@
const {
Client,
Events,
EmbedBuilder,
REST,
Routes,
ActivityType,
} = require("discord.js");
const { generateDependencyReport } = require("@discordjs/voice");
const intents = require("./config/intents");
const config = require("./config/config");
const { randElement } = require("./utils/helpers");
// Log audio dependencies status
console.log("Audio Dependencies Status:");
console.log(generateDependencyReport());
// Initialize Discord client
const client = new Client({ intents });
// Services
const commandLoader = require("./services/commandLoader");
const schedulerService = require("./services/schedulerService");
// Command handlers
const prefixCommands = require("./commands/prefix");
const slashCommands = require("./commands/slash");
// Activity rotation
let activityInterval;
/**
* Set a random activity for the bot
*/
function setRandomActivity() {
const activity =
config.discord.activities?.length > 0
? randElement(config.discord.activities)
: "DESTROY ALL HUMANS";
console.log(`Setting Discord activity to: ${activity}`);
client.user.setActivity(activity, {
url: "https://twitch.tv/fgfm",
type: ActivityType.Streaming,
});
}
/**
* Register slash commands
*/
async function registerSlashCommands() {
const rest = new REST({ version: "10" }).setToken(config.discord.token);
try {
console.log("Started refreshing application (/) commands.");
// Get all slash command definitions
const commands = slashCommands.getSlashCommandDefinitions();
// Register commands for each guild
for (const guild of config.discord.guilds) {
await rest.put(
Routes.applicationGuildCommands(client.user.id, guild.id),
{ body: commands }
);
console.log(
`Registered slash commands for guild: ${guild.internalName || guild.id}`
);
}
console.log("Successfully reloaded application (/) commands.");
} catch (error) {
console.error("Error registering slash commands:", error);
}
}
// Client ready event
client.once(Events.ClientReady, async () => {
console.log(`${config.botName} is connected and ready!`);
console.log(`Logged in as ${client.user.tag}`);
console.log(`Serving ${client.guilds.cache.size} guild(s)`);
// Set initial activity
setRandomActivity();
// Rotate activity every hour
activityInterval = setInterval(() => {
setRandomActivity();
}, 3600 * 1000);
// Register slash commands
await registerSlashCommands();
// Initialize scheduled events
schedulerService.initialize(client, config);
});
// Message handler for prefix commands
client.on(Events.MessageCreate, async (message) => {
// Ignore bot messages
if (message.author.bot) return;
// Ignore DMs if not configured
if (!message.guild) return;
// Check if guild is configured
const guildConfig = config.discord.guilds.find(
(g) => g.id === message.guild.id
);
if (!guildConfig) return;
// Check blacklist
if (config.discord.blacklistedUsers?.includes(message.author.id)) return;
// Check for command prefix
if (!message.content.startsWith(guildConfig.prefix)) return;
// Parse command
const args = message.content
.slice(guildConfig.prefix.length)
.trim()
.split(/ +/);
const commandName = args.shift().toLowerCase();
console.log(
`Command '${commandName}' received in ${
guildConfig.internalName || message.guild.name
}#${message.channel.name} from @${message.author.username}`
);
try {
// Check for prefix commands
if (prefixCommands.has(commandName)) {
await prefixCommands.execute(commandName, message, args, guildConfig);
return;
}
// Check for static commands
if (commandLoader.hasStaticCommand(commandName)) {
const response = commandLoader.getStaticCommand(commandName);
const embed = new EmbedBuilder()
.setTitle(commandName)
.setColor(0x21c629)
.setDescription(response);
await message.channel.send({ embeds: [embed] });
return;
}
// Check for Ankhbot commands
if (commandLoader.hasAnkhbotCommand(commandName)) {
const response = commandLoader.getAnkhbotCommand(commandName);
const embed = new EmbedBuilder()
.setTitle(commandName)
.setColor(0x21c629)
.setDescription(response);
await message.channel.send({ embeds: [embed] });
return;
}
// Command not found - ignore silently
} catch (error) {
console.error(`Error executing command ${commandName}:`, error);
message
.reply("There was an error executing that command!")
.catch(console.error);
}
});
// Interaction handler for slash commands
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() && !interaction.isAutocomplete())
return;
// Get guild config
const guildConfig = config.discord.guilds.find(
(g) => g.id === interaction.guild.id
);
if (!guildConfig) return;
try {
if (interaction.isAutocomplete()) {
await slashCommands.handleAutocomplete(interaction, guildConfig);
} else if (interaction.isChatInputCommand()) {
await slashCommands.execute(
interaction.commandName,
interaction,
guildConfig
);
}
} catch (error) {
console.error("Error handling interaction:", error);
if (interaction.isChatInputCommand() && !interaction.replied) {
await interaction
.reply({
content: "There was an error executing this command!",
ephemeral: true,
})
.catch(console.error);
}
}
});
// Handle new guild members
client.on(Events.GuildMemberAdd, (member) => {
// Check if guild is configured
const guildConfig = config.discord.guilds.find(
(g) => g.id === member.guild.id
);
if (!guildConfig) return;
console.log(
`A new member has joined '${member.guild.name}': ${member.displayName}`
);
});
// Handle guild becoming unavailable
client.on(Events.GuildUnavailable, (guild) => {
console.log(
`Guild '${guild.name}' is no longer available! Most likely due to server outage.`
);
});
// Debug logging
client.on("debug", (info) => {
if (config.debug === true) {
console.log(`[${new Date().toISOString()}] DEBUG: ${info}`);
}
});
// Error handling
client.on("error", console.error);
// Process error handling
process.on("unhandledRejection", console.error);
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM signal received, shutting down gracefully...");
if (activityInterval) {
clearInterval(activityInterval);
}
client.destroy();
process.exit(0);
});
// Login to Discord
client.login(config.discord.token);

View File

@@ -0,0 +1,166 @@
const fs = require('fs');
const path = require('path');
class CommandLoader {
constructor() {
this.staticCommands = {};
this.ankhbotCommands = {};
// Paths to command files
this.staticCommandsPath = path.join(__dirname, '..', '..', 'conf', 'text_commands');
this.ankhbotCommandsPath = path.join(__dirname, '..', '..', 'conf', 'ghbot.abcomg');
// Load commands initially
this.loadStaticCommands();
this.loadAnkhbotCommands();
// Watch for changes
this.watchFiles();
}
/**
* Load static text commands from file
*/
loadStaticCommands() {
try {
if (!fs.existsSync(this.staticCommandsPath)) {
console.log('Static commands file not found, skipping...');
return;
}
const data = fs.readFileSync(this.staticCommandsPath, 'utf-8');
const lines = data.toString().split('\n');
const commands = {};
lines.forEach(line => {
if (line.length > 0 && line.indexOf('|') !== -1) {
const parts = line.split('|');
// Check for aliases (comma-separated)
const aliases = parts[0].split(',');
aliases.forEach(cmd => {
commands[cmd.trim()] = parts[1];
});
}
});
this.staticCommands = commands;
console.log(`Loaded ${Object.keys(commands).length} static commands`);
} catch (error) {
console.error('Error loading static commands:', error);
}
}
/**
* Load Ankhbot commands from file
*/
loadAnkhbotCommands() {
try {
if (!fs.existsSync(this.ankhbotCommandsPath)) {
console.log('Ankhbot commands file not found, skipping...');
return;
}
const data = fs.readFileSync(this.ankhbotCommandsPath, 'utf-8');
// Try to parse as JSON first, fall back to eval if needed (for legacy format)
let commands;
try {
commands = JSON.parse(data);
} catch {
// Legacy format might use JavaScript object notation
// Create a safer evaluation context
const sandbox = { commands: null };
const script = `commands = ${data}`;
try {
// Use Function constructor for safer eval
new Function('commands', script).call(sandbox, sandbox);
commands = sandbox.commands;
} catch (e) {
console.error('Failed to parse Ankhbot commands:', e);
return;
}
}
// Convert to a map for easier lookup
const commandMap = {};
if (Array.isArray(commands)) {
commands.forEach(cmd => {
if (cmd.Enabled === true && cmd.Command && cmd.Response) {
// Remove prefix from command name for storage
const cmdName = cmd.Command.startsWith('!') ?
cmd.Command.substring(1) : cmd.Command;
commandMap[cmdName] = cmd.Response;
}
});
}
this.ankhbotCommands = commandMap;
console.log(`Loaded ${Object.keys(commandMap).length} Ankhbot commands`);
} catch (error) {
console.error('Error loading Ankhbot commands:', error);
}
}
/**
* Watch command files for changes
*/
watchFiles() {
// Watch static commands file
if (fs.existsSync(this.staticCommandsPath)) {
fs.watchFile(this.staticCommandsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Static commands file changed, reloading...');
this.loadStaticCommands();
}
});
}
// Watch Ankhbot commands file
if (fs.existsSync(this.ankhbotCommandsPath)) {
fs.watchFile(this.ankhbotCommandsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Ankhbot commands file changed, reloading...');
this.loadAnkhbotCommands();
}
});
}
}
/**
* Check if a static command exists
* @param {string} command
* @returns {boolean}
*/
hasStaticCommand(command) {
return this.staticCommands.hasOwnProperty(command);
}
/**
* Get a static command response
* @param {string} command
* @returns {string|undefined}
*/
getStaticCommand(command) {
return this.staticCommands[command];
}
/**
* Check if an Ankhbot command exists
* @param {string} command
* @returns {boolean}
*/
hasAnkhbotCommand(command) {
return this.ankhbotCommands.hasOwnProperty(command);
}
/**
* Get an Ankhbot command response
* @param {string} command
* @returns {string|undefined}
*/
getAnkhbotCommand(command) {
return this.ankhbotCommands[command];
}
}
module.exports = new CommandLoader();

View File

@@ -0,0 +1,157 @@
const schedule = require('node-schedule');
class SchedulerService {
constructor() {
this.jobs = new Map();
}
/**
* Initialize scheduled events for all guilds
* @param {Client} client
* @param {Object} config
*/
async initialize(client, config) {
console.log('Initializing scheduled events...');
for (const guildConfig of config.discord.guilds) {
try {
const guild = await client.guilds.fetch(guildConfig.id);
if (!guild) {
console.error(`Could not find guild ${guildConfig.id}`);
continue;
}
if (!guildConfig.scheduledEvents || guildConfig.scheduledEvents.length === 0) {
continue;
}
for (const event of guildConfig.scheduledEvents) {
await this.scheduleEvent(guild, event, guildConfig);
}
} catch (error) {
console.error(`Error setting up scheduled events for guild ${guildConfig.id}:`, error);
}
}
}
/**
* Schedule a single event
* @param {Guild} guild
* @param {Object} event
* @param {Object} guildConfig
*/
async scheduleEvent(guild, event, guildConfig) {
try {
// Validate channel
let channel = null;
if (event.channelId) {
channel = await guild.channels.fetch(event.channelId);
if (!channel) {
console.error(`Invalid channel ${event.channelId} for event ${event.id} in guild ${guild.name}`);
return;
}
}
// Validate role
let pingRole = null;
if (event.pingRoleId) {
pingRole = await guild.roles.fetch(event.pingRoleId);
if (!pingRole) {
console.warn(`Invalid role ${event.pingRoleId} for event ${event.id} in guild ${guild.name}`);
}
}
console.log(`Scheduling event ${event.id} for ${guild.name}...`);
// Create the scheduled job
const job = schedule.scheduleJob(event.schedule, () => {
this.executeEvent(channel, event, pingRole);
});
if (job) {
// Store job reference
const jobKey = `${guild.id}-${event.id}`;
this.jobs.set(jobKey, job);
console.log(`Event ${event.id} scheduled. Next invocation: ${job.nextInvocation()}`);
} else {
console.error(`Failed to schedule event ${event.id} - invalid cron expression: ${event.schedule}`);
}
} catch (error) {
console.error(`Error scheduling event ${event.id}:`, error);
}
}
/**
* Execute a scheduled event
* @param {TextChannel} channel
* @param {Object} event
* @param {Role} pingRole
*/
async executeEvent(channel, event, pingRole) {
try {
const content = [];
// Add role ping if configured
if (pingRole) {
content.push(pingRole.toString());
}
// Add message if configured
if (event.message) {
content.push(event.message);
}
// Send the message
if (content.length > 0 && channel) {
await channel.send(content.join(' '));
console.log(`Executed scheduled event ${event.id}`);
}
} catch (error) {
console.error(`Error executing scheduled event ${event.id}:`, error);
}
}
/**
* Cancel a scheduled job
* @param {string} guildId
* @param {string} eventId
*/
cancelJob(guildId, eventId) {
const jobKey = `${guildId}-${eventId}`;
const job = this.jobs.get(jobKey);
if (job) {
job.cancel();
this.jobs.delete(jobKey);
console.log(`Cancelled scheduled event ${eventId} for guild ${guildId}`);
}
}
/**
* Cancel all jobs for a guild
* @param {string} guildId
*/
cancelGuildJobs(guildId) {
for (const [key, job] of this.jobs) {
if (key.startsWith(`${guildId}-`)) {
job.cancel();
this.jobs.delete(key);
}
}
console.log(`Cancelled all scheduled events for guild ${guildId}`);
}
/**
* Cancel all jobs
*/
cancelAllJobs() {
for (const job of this.jobs.values()) {
job.cancel();
}
this.jobs.clear();
console.log('Cancelled all scheduled events');
}
}
module.exports = new SchedulerService();

113
src/services/sfxManager.js Normal file
View File

@@ -0,0 +1,113 @@
const fs = require('fs');
const path = require('path');
class SFXManager {
constructor() {
this.sfxPath = path.join(__dirname, '..', '..', 'sfx');
this.sfxList = [];
// Load SFX list initially
this.loadSFXList();
// Watch for changes
this.watchSFXDirectory();
}
/**
* Load the list of available SFX files
*/
loadSFXList() {
try {
if (!fs.existsSync(this.sfxPath)) {
console.log('SFX directory not found, creating...');
fs.mkdirSync(this.sfxPath, { recursive: true });
}
const files = fs.readdirSync(this.sfxPath);
this.sfxList = files
.filter(file => file.endsWith('.mp3') || file.endsWith('.wav'))
.map(file => {
const ext = path.extname(file);
return {
name: file.replace(ext, ''),
filename: file,
path: path.join(this.sfxPath, file)
};
});
console.log(`Loaded ${this.sfxList.length} sound effects`);
} catch (error) {
console.error('Error loading SFX list:', error);
}
}
/**
* Watch the SFX directory for changes
*/
watchSFXDirectory() {
fs.watch(this.sfxPath, (eventType, filename) => {
if (eventType === 'rename') {
console.log('SFX directory changed, reloading...');
this.loadSFXList();
}
});
}
/**
* Get all available SFX
* @returns {Array} List of SFX objects
*/
getAllSFX() {
return this.sfxList;
}
/**
* Get SFX names for autocomplete
* @returns {Array} List of SFX names
*/
getSFXNames() {
return this.sfxList.map(sfx => sfx.name);
}
/**
* Find an SFX by name
* @param {string} name
* @returns {Object|undefined} SFX object or undefined
*/
findSFX(name) {
return this.sfxList.find(sfx => sfx.name.toLowerCase() === name.toLowerCase());
}
/**
* Check if an SFX exists
* @param {string} name
* @returns {boolean}
*/
hasSFX(name) {
return this.findSFX(name) !== undefined;
}
/**
* Get the file path for an SFX
* @param {string} name
* @returns {string|null}
*/
getSFXPath(name) {
const sfx = this.findSFX(name);
return sfx ? sfx.path : null;
}
/**
* Search SFX names (for autocomplete)
* @param {string} query
* @returns {Array} Matching SFX names
*/
searchSFX(query) {
const lowerQuery = query.toLowerCase();
return this.sfxList
.filter(sfx => sfx.name.toLowerCase().includes(lowerQuery))
.map(sfx => sfx.name);
}
}
module.exports = new SFXManager();

View File

@@ -0,0 +1,171 @@
const {
createAudioPlayer,
createAudioResource,
joinVoiceChannel,
VoiceConnectionStatus,
AudioPlayerStatus,
entersState,
getVoiceConnection,
generateDependencyReport
} = require('@discordjs/voice');
const { ChannelType } = require('discord.js');
// Try to use ffmpeg-static as fallback if system ffmpeg is not available
try {
const ffmpegPath = require('ffmpeg-static');
if (ffmpegPath && !process.env.FFMPEG_PATH) {
process.env.FFMPEG_PATH = ffmpegPath;
}
} catch (error) {
// ffmpeg-static not available, rely on system ffmpeg
}
class VoiceService {
constructor() {
this.connections = new Map();
this.players = new Map();
}
/**
* Join a voice channel
* @param {VoiceChannel} channel
* @returns {VoiceConnection}
*/
async join(channel) {
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error('Invalid voice channel');
}
// Check if already connected
let connection = getVoiceConnection(channel.guild.id);
if (!connection) {
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
// Wait for connection to be ready
try {
await entersState(connection, VoiceConnectionStatus.Ready, 10_000);
} catch (error) {
connection.destroy();
throw error;
}
// Store connection
this.connections.set(channel.guild.id, connection);
// Handle disconnection
connection.on(VoiceConnectionStatus.Disconnected, async () => {
try {
// Try to reconnect
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch (error) {
// Seems to be a real disconnect, destroy the connection
connection.destroy();
this.connections.delete(channel.guild.id);
this.players.delete(channel.guild.id);
}
});
}
return connection;
}
/**
* Leave a voice channel
* @param {string} guildId
*/
leave(guildId) {
const connection = this.connections.get(guildId);
if (connection) {
connection.destroy();
this.connections.delete(guildId);
this.players.delete(guildId);
}
}
/**
* Play an audio file
* @param {string} guildId
* @param {string} filePath
* @param {Object} options
* @returns {AudioPlayer}
*/
async play(guildId, filePath, options = {}) {
const connection = this.connections.get(guildId);
if (!connection) {
throw new Error('Not connected to voice channel');
}
// Create or get player for this guild
let player = this.players.get(guildId);
if (!player) {
player = createAudioPlayer();
this.players.set(guildId, player);
}
// Create audio resource with options
const resource = createAudioResource(filePath, {
inlineVolume: options.volume !== undefined,
});
if (options.volume !== undefined && resource.volume) {
resource.volume.setVolume(options.volume);
}
// Subscribe the connection to the player
connection.subscribe(player);
// Play the resource
player.play(resource);
// Return a promise that resolves when playback finishes
return new Promise((resolve, reject) => {
player.once(AudioPlayerStatus.Idle, () => {
resolve();
});
player.once('error', (error) => {
console.error('Player error:', error);
reject(error);
});
});
}
/**
* Stop playing audio
* @param {string} guildId
*/
stop(guildId) {
const player = this.players.get(guildId);
if (player) {
player.stop();
}
}
/**
* Check if connected to a voice channel
* @param {string} guildId
* @returns {boolean}
*/
isConnected(guildId) {
return this.connections.has(guildId);
}
/**
* Get the current voice connection
* @param {string} guildId
* @returns {VoiceConnection|undefined}
*/
getConnection(guildId) {
return this.connections.get(guildId);
}
}
module.exports = new VoiceService();

70
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* Pick a random element from an array
* @param {Array} arr
* @returns {*} Random element from the array
*/
function randElement(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* Random sort function
* @returns {number}
*/
function randSort() {
return 0.5 - Math.random();
}
/**
* Split a string into chunks of specified size
* @param {string} str
* @param {number} size
* @returns {string[]}
*/
function chunkSubstr(str, size) {
const numChunks = Math.ceil(str.length / size);
const chunks = new Array(numChunks);
for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
chunks[i] = str.substr(o, size);
}
return chunks;
}
/**
* Async forEach implementation
* @param {Array} array
* @param {Function} callback
*/
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
/**
* Converts seconds to human-readable time
* @param {number} seconds
* @returns {string} HH:MM:SS format
*/
function toHHMMSS(seconds) {
const sec_num = parseInt(seconds, 10);
let hours = Math.floor(sec_num / 3600);
let minutes = Math.floor((sec_num - hours * 3600) / 60);
let secs = sec_num - hours * 3600 - minutes * 60;
if (hours < 10) hours = "0" + hours;
if (minutes < 10) minutes = "0" + minutes;
if (secs < 10) secs = "0" + secs;
return hours + ":" + minutes + ":" + secs;
}
module.exports = {
randElement,
randSort,
chunkSubstr,
asyncForEach,
toHHMMSS
};