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:
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();
|
||||
Reference in New Issue
Block a user