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:
10
src/commands/prefix/dance.js
Normal file
10
src/commands/prefix/dance.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
name: 'dance',
|
||||
description: 'Make the bot dance!',
|
||||
|
||||
async execute(message, args, guildConfig) {
|
||||
await message.channel.send(
|
||||
'*┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛*'
|
||||
);
|
||||
}
|
||||
};
|
||||
79
src/commands/prefix/funfact.js
Normal file
79
src/commands/prefix/funfact.js
Normal 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);
|
||||
}
|
||||
};
|
||||
79
src/commands/prefix/hamfact.js
Normal file
79
src/commands/prefix/hamfact.js
Normal 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);
|
||||
}
|
||||
};
|
||||
67
src/commands/prefix/index.js
Normal file
67
src/commands/prefix/index.js
Normal 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();
|
||||
26
src/commands/prefix/join.js
Normal file
26
src/commands/prefix/join.js
Normal 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!");
|
||||
}
|
||||
}
|
||||
};
|
||||
16
src/commands/prefix/leave.js
Normal file
16
src/commands/prefix/leave.js
Normal 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('👋');
|
||||
}
|
||||
};
|
||||
19
src/commands/prefix/reboot.js
Normal file
19
src/commands/prefix/reboot.js
Normal 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);
|
||||
},
|
||||
};
|
||||
74
src/commands/prefix/role.js
Normal file
74
src/commands/prefix/role.js
Normal 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!');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
87
src/commands/prefix/sfx.js
Normal file
87
src/commands/prefix/sfx.js
Normal 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!");
|
||||
}
|
||||
}
|
||||
};
|
||||
77
src/commands/slash/index.js
Normal file
77
src/commands/slash/index.js
Normal 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
108
src/commands/slash/sfx.js
Normal 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
26
src/config/config.js
Normal 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
10
src/config/intents.js
Normal 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
253
src/index.js
Normal 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);
|
||||
166
src/services/commandLoader.js
Normal file
166
src/services/commandLoader.js
Normal 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();
|
||||
157
src/services/schedulerService.js
Normal file
157
src/services/schedulerService.js
Normal 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
113
src/services/sfxManager.js
Normal 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();
|
||||
171
src/services/voiceService.js
Normal file
171
src/services/voiceService.js
Normal 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
70
src/utils/helpers.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user