feat(bot): add NodeJS implementation and deploy script
This commit is contained in:
parent
5c632556b7
commit
74dfdbf667
61
deploy-commands.js
Normal file
61
deploy-commands.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const { REST, Routes } = require('discord.js');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const logger = require('./src/utils/logger'); // Assuming logger is setup
|
||||||
|
require('dotenv').config(); // Load .env variables
|
||||||
|
|
||||||
|
// --- Configuration ---
|
||||||
|
const clientId = process.env.CLIENT_ID;
|
||||||
|
const token = process.env.DISCORD_TOKEN;
|
||||||
|
// const guildId = process.env.GUILD_ID; // Uncomment for guild-specific commands during testing
|
||||||
|
|
||||||
|
if (!clientId || !token) {
|
||||||
|
logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = [];
|
||||||
|
// Grab all the command files from the commands directory you created earlier
|
||||||
|
const commandsPath = path.join(__dirname, 'src', 'commands');
|
||||||
|
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
|
||||||
|
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = path.join(commandsPath, file);
|
||||||
|
try {
|
||||||
|
const command = require(filePath);
|
||||||
|
if ('data' in command && 'execute' in command) {
|
||||||
|
commands.push(command.data.toJSON());
|
||||||
|
logger.info(`Loaded command: ${command.data.name}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error loading command at ${filePath} for deployment: ${error.message}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct and prepare an instance of the REST module
|
||||||
|
const rest = new REST({ version: '10' }).setToken(token);
|
||||||
|
|
||||||
|
// and deploy your commands!
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
logger.info(`Started refreshing ${commands.length} application (/) commands.`);
|
||||||
|
|
||||||
|
// The put method is used to fully refresh all commands in the guild with the current set
|
||||||
|
// Use Routes.applicationCommands(clientId) for global deployment
|
||||||
|
// Use Routes.applicationGuildCommands(clientId, guildId) for guild-specific deployment
|
||||||
|
const data = await rest.put(
|
||||||
|
Routes.applicationCommands(clientId), // Deploy globally
|
||||||
|
// Routes.applicationGuildCommands(clientId, guildId), // Deploy to specific guild (for testing)
|
||||||
|
{ body: commands },
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Successfully reloaded ${data.length} application (/) commands globally.`);
|
||||||
|
} catch (error) {
|
||||||
|
// And of course, make sure you catch and log any errors!
|
||||||
|
logger.error('Failed to refresh application commands:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-music-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.18.0",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"erela.js": "^2.4.0",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/commands/join.js
Normal file
74
src/commands/join.js
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('join')
|
||||||
|
.setDescription('Joins your current voice channel'),
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
await interaction.deferReply({ ephemeral: true }); // Defer reply as joining might take time
|
||||||
|
|
||||||
|
const member = interaction.member;
|
||||||
|
const voiceChannel = member?.voice?.channel;
|
||||||
|
|
||||||
|
// 1. Check if user is in a voice channel
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return interaction.editReply('You need to be in a voice channel to use this command!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check bot permissions
|
||||||
|
const permissions = voiceChannel.permissionsFor(client.user);
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Connect)) {
|
||||||
|
return interaction.editReply('I need permission to **connect** to your voice channel!');
|
||||||
|
}
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Speak)) {
|
||||||
|
return interaction.editReply('I need permission to **speak** in your voice channel!');
|
||||||
|
}
|
||||||
|
// Ensure it's a voice channel (not stage, etc.) although erela might handle this
|
||||||
|
if (voiceChannel.type !== ChannelType.GuildVoice) {
|
||||||
|
return interaction.editReply('I can only join standard voice channels.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create or get the player and connect
|
||||||
|
let player = client.manager.get(interaction.guildId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
try {
|
||||||
|
player = client.manager.create({
|
||||||
|
guild: interaction.guildId,
|
||||||
|
voiceChannel: voiceChannel.id,
|
||||||
|
textChannel: interaction.channelId, // Store the channel where command was used
|
||||||
|
selfDeafen: true, // Automatically deafen the bot
|
||||||
|
// selfMute: false, // Bot starts unmuted
|
||||||
|
});
|
||||||
|
player.connect();
|
||||||
|
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`);
|
||||||
|
await interaction.editReply(`Joined ${voiceChannel.name}! Ready to play music.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
// Try to destroy player if partially created
|
||||||
|
if (player) player.destroy();
|
||||||
|
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If player exists but is not connected or in a different channel
|
||||||
|
if (player.voiceChannel !== voiceChannel.id) {
|
||||||
|
player.setVoiceChannel(voiceChannel.id);
|
||||||
|
if (!player.playing && !player.paused && !player.queue.size) {
|
||||||
|
player.connect(); // Connect if not already playing/paused/queued
|
||||||
|
}
|
||||||
|
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guildId}`);
|
||||||
|
await interaction.editReply(`Moved to ${voiceChannel.name}!`);
|
||||||
|
} else {
|
||||||
|
// Already in the correct channel
|
||||||
|
await interaction.editReply(`I'm already in ${voiceChannel.name}!`);
|
||||||
|
}
|
||||||
|
// Update text channel if needed
|
||||||
|
if (player.textChannel !== interaction.channelId) {
|
||||||
|
player.setTextChannel(interaction.channelId);
|
||||||
|
logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
39
src/commands/leave.js
Normal file
39
src/commands/leave.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('leave')
|
||||||
|
.setDescription('Leaves the current voice channel'),
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
|
const player = client.manager.get(interaction.guildId);
|
||||||
|
|
||||||
|
// Check if the player exists and the bot is in a voice channel
|
||||||
|
if (!player || !player.voiceChannel) {
|
||||||
|
return interaction.editReply('I am not currently in a voice channel!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check if the user is in the same channel as the bot
|
||||||
|
// const memberVoiceChannel = interaction.member?.voice?.channelId;
|
||||||
|
// if (memberVoiceChannel !== player.voiceChannel) {
|
||||||
|
// return interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channelId = player.voiceChannel;
|
||||||
|
const channel = client.channels.cache.get(channelId);
|
||||||
|
const channelName = channel ? channel.name : `ID: ${channelId}`; // Get channel name if possible
|
||||||
|
|
||||||
|
player.destroy(); // Disconnects, clears queue, and destroys the player instance
|
||||||
|
logger.info(`Player destroyed and left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`);
|
||||||
|
await interaction.editReply(`Left ${channelName}.`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error destroying player for guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
// Attempt to reply even if destroy failed partially
|
||||||
|
await interaction.editReply('An error occurred while trying to leave the voice channel.').catch(e => logger.error(`Failed to send error reply for leave command: ${e.message}`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
15
src/commands/ping.js
Normal file
15
src/commands/ping.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const { SlashCommandBuilder } = require('discord.js');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('ping')
|
||||||
|
.setDescription('Replies with Pong!'),
|
||||||
|
async execute(interaction) {
|
||||||
|
// Calculate latency (optional but common for ping commands)
|
||||||
|
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true, ephemeral: true });
|
||||||
|
const latency = sent.createdTimestamp - interaction.createdTimestamp;
|
||||||
|
const wsPing = interaction.client.ws.ping; // WebSocket heartbeat ping
|
||||||
|
|
||||||
|
await interaction.editReply(`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`);
|
||||||
|
},
|
||||||
|
};
|
||||||
157
src/commands/play.js
Normal file
157
src/commands/play.js
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('play')
|
||||||
|
.setDescription('Plays audio from a URL or search query')
|
||||||
|
.addStringOption(option =>
|
||||||
|
option.setName('query')
|
||||||
|
.setDescription('The URL or search term for the song/playlist')
|
||||||
|
.setRequired(true)),
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
await interaction.deferReply(); // Defer reply immediately
|
||||||
|
|
||||||
|
const member = interaction.member;
|
||||||
|
const voiceChannel = member?.voice?.channel;
|
||||||
|
const query = interaction.options.getString('query');
|
||||||
|
|
||||||
|
// 1. Check if user is in a voice channel
|
||||||
|
if (!voiceChannel) {
|
||||||
|
return interaction.editReply('You need to be in a voice channel to play music!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check bot permissions
|
||||||
|
const permissions = voiceChannel.permissionsFor(client.user);
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Connect)) {
|
||||||
|
return interaction.editReply('I need permission to **connect** to your voice channel!');
|
||||||
|
}
|
||||||
|
if (!permissions.has(PermissionFlagsBits.Speak)) {
|
||||||
|
return interaction.editReply('I need permission to **speak** in your voice channel!');
|
||||||
|
}
|
||||||
|
if (voiceChannel.type !== ChannelType.GuildVoice) {
|
||||||
|
return interaction.editReply('I can only join standard voice channels.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get or create player, connect if necessary
|
||||||
|
let player = client.manager.get(interaction.guildId);
|
||||||
|
if (!player) {
|
||||||
|
try {
|
||||||
|
player = client.manager.create({
|
||||||
|
guild: interaction.guildId,
|
||||||
|
voiceChannel: voiceChannel.id,
|
||||||
|
textChannel: interaction.channelId,
|
||||||
|
selfDeafen: true,
|
||||||
|
});
|
||||||
|
player.connect();
|
||||||
|
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`, error);
|
||||||
|
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure bot is in the user's channel if already connected elsewhere
|
||||||
|
if (player.voiceChannel !== voiceChannel.id) {
|
||||||
|
// Optional: Add check if user has permission to move bot or if bot is already playing
|
||||||
|
// if (player.playing) {
|
||||||
|
// return interaction.editReply("I'm currently playing in another channel!");
|
||||||
|
// }
|
||||||
|
player.setVoiceChannel(voiceChannel.id);
|
||||||
|
if (!player.playing && !player.paused && !player.queue.size) {
|
||||||
|
player.connect();
|
||||||
|
}
|
||||||
|
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
||||||
|
}
|
||||||
|
// Update text channel if needed
|
||||||
|
if (player.textChannel !== interaction.channelId) {
|
||||||
|
player.setTextChannel(interaction.channelId);
|
||||||
|
logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Search for tracks
|
||||||
|
try {
|
||||||
|
const searchResult = await player.search(query, interaction.user); // Pass interaction.user as requester
|
||||||
|
|
||||||
|
// --- Handle Search Results ---
|
||||||
|
let responseEmbed = new EmbedBuilder().setColor('#0099ff');
|
||||||
|
|
||||||
|
switch (searchResult.loadType) {
|
||||||
|
case 'LOAD_FAILED':
|
||||||
|
logger.error(`Search failed for query "${query}" in guild ${interaction.guildId}. Error: ${searchResult.exception?.message}`);
|
||||||
|
await interaction.editReply(`Oops! I couldn't load tracks for your query. Error: ${searchResult.exception?.message || 'Unknown error'}`);
|
||||||
|
// Optionally destroy player if nothing is playing/queued
|
||||||
|
if (!player.playing && !player.paused && player.queue.isEmpty) {
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'NO_MATCHES':
|
||||||
|
await interaction.editReply(`No results found for "${query}".`);
|
||||||
|
if (!player.playing && !player.paused && player.queue.isEmpty) {
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'TRACK_LOADED':
|
||||||
|
const track = searchResult.tracks[0];
|
||||||
|
player.queue.add(track);
|
||||||
|
logger.info(`Added track to queue: ${track.title} (Guild: ${interaction.guildId})`);
|
||||||
|
|
||||||
|
responseEmbed
|
||||||
|
.setTitle('Track Added to Queue')
|
||||||
|
.setDescription(`[${track.title}](${track.uri})`)
|
||||||
|
.addFields({ name: 'Position in queue', value: `${player.queue.size}`, inline: true });
|
||||||
|
if (track.thumbnail) {
|
||||||
|
responseEmbed.setThumbnail(track.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [responseEmbed] });
|
||||||
|
break; // Proceed to play check
|
||||||
|
|
||||||
|
case 'PLAYLIST_LOADED':
|
||||||
|
player.queue.add(searchResult.tracks);
|
||||||
|
logger.info(`Added playlist: ${searchResult.playlist?.name} (${searchResult.tracks.length} tracks) (Guild: ${interaction.guildId})`);
|
||||||
|
|
||||||
|
responseEmbed
|
||||||
|
.setTitle('Playlist Added to Queue')
|
||||||
|
.setDescription(`**${searchResult.playlist?.name || 'Unnamed Playlist'}** (${searchResult.tracks.length} tracks)`)
|
||||||
|
.addFields({ name: 'Starting track', value: `[${searchResult.tracks[0].title}](${searchResult.tracks[0].uri})` });
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [responseEmbed] });
|
||||||
|
break; // Proceed to play check
|
||||||
|
|
||||||
|
case 'SEARCH_RESULT':
|
||||||
|
const firstTrack = searchResult.tracks[0];
|
||||||
|
player.queue.add(firstTrack);
|
||||||
|
logger.info(`Added search result to queue: ${firstTrack.title} (Guild: ${interaction.guildId})`);
|
||||||
|
|
||||||
|
responseEmbed
|
||||||
|
.setTitle('Track Added to Queue')
|
||||||
|
.setDescription(`[${firstTrack.title}](${firstTrack.uri})`)
|
||||||
|
.addFields({ name: 'Position in queue', value: `${player.queue.size}`, inline: true });
|
||||||
|
if (firstTrack.thumbnail) {
|
||||||
|
responseEmbed.setThumbnail(firstTrack.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [responseEmbed] });
|
||||||
|
break; // Proceed to play check
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Start playing if not already playing
|
||||||
|
if (!player.playing && !player.paused && player.queue.totalSize > 0) {
|
||||||
|
player.play();
|
||||||
|
logger.info(`Started playing in guild ${interaction.guildId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`, error);
|
||||||
|
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e => logger.error(`Failed to send error reply for play command: ${e.message}`));
|
||||||
|
// Optionally destroy player on critical error
|
||||||
|
// if (!player.playing && !player.paused && player.queue.isEmpty) {
|
||||||
|
// player.destroy();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
41
src/events/interactionCreate.js
Normal file
41
src/events/interactionCreate.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const { Events, InteractionType } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.InteractionCreate,
|
||||||
|
async execute(interaction, client) { // Added client parameter
|
||||||
|
// Handle only slash commands (ChatInputCommand) for now
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
logger.error(`No command matching ${interaction.commandName} was found.`);
|
||||||
|
try {
|
||||||
|
await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true });
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.error(`Failed to send 'command not found' reply: ${replyError.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the command's logic
|
||||||
|
await command.execute(interaction, client); // Pass client to command execute
|
||||||
|
logger.info(`Executed command '${interaction.commandName}' for user ${interaction.user.tag}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error executing command '${interaction.commandName}': ${error.message}`, error);
|
||||||
|
// Try to reply to the interaction, otherwise edit the deferred reply if applicable
|
||||||
|
const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true };
|
||||||
|
try {
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp(replyOptions);
|
||||||
|
} else {
|
||||||
|
await interaction.reply(replyOptions);
|
||||||
|
}
|
||||||
|
} catch (replyError) {
|
||||||
|
logger.error(`Failed to send error reply for command '${interaction.commandName}': ${replyError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
31
src/events/ready.js
Normal file
31
src/events/ready.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
const { Events } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const loadErelaEvents = require('../structures/ErelaEvents'); // Import the Erela event loader
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.ClientReady,
|
||||||
|
once: true, // This event should only run once
|
||||||
|
execute(client) {
|
||||||
|
logger.info(`Ready! Logged in as ${client.user.tag}`);
|
||||||
|
|
||||||
|
// Initialize the Erela Manager once the client is ready
|
||||||
|
try {
|
||||||
|
client.manager.init(client.user.id);
|
||||||
|
logger.info(`Erela.js Manager initialized for user ID: ${client.user.id}`);
|
||||||
|
|
||||||
|
// Load Erela.js event handlers
|
||||||
|
loadErelaEvents(client);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize Erela.js Manager: ${error.message}`);
|
||||||
|
// Depending on requirements, you might want to exit or handle this differently
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for setting activity, etc.
|
||||||
|
// client.user.setActivity('Music!', { type: 'LISTENING' });
|
||||||
|
|
||||||
|
// Note: Slash command registration is typically done in a separate deploy script,
|
||||||
|
// not usually within the ready event for production bots.
|
||||||
|
// We will create a deploy-commands.js script later.
|
||||||
|
},
|
||||||
|
};
|
||||||
49
src/events/voiceStateUpdate.js
Normal file
49
src/events/voiceStateUpdate.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const { Events } = require('discord.js');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
name: Events.VoiceStateUpdate,
|
||||||
|
execute(oldState, newState, client) { // Added client parameter
|
||||||
|
// Pass the event data to the Erela.js manager
|
||||||
|
// It handles the logic for joining/leaving channels, server muting/deafening, etc.
|
||||||
|
if (client.manager) {
|
||||||
|
try {
|
||||||
|
// Use newState primarily, as erela.js handles the diff internally
|
||||||
|
client.manager.voiceStateUpdate(newState);
|
||||||
|
// Optional: Add more specific logging if needed
|
||||||
|
// logger.debug(`Voice state update processed for user ${newState.member?.user?.tag || 'Unknown'} in guild ${newState.guild.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error processing voice state update: ${error.message}`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('Voice state update received, but Erela.js manager is not initialized yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// You can add custom logic here if needed, for example:
|
||||||
|
// - Check if the bot itself was disconnected and clean up the player.
|
||||||
|
// - Check if the channel the bot was in becomes empty.
|
||||||
|
const player = client.manager?.players.get(newState.guild.id);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
// Check if the bot was disconnected
|
||||||
|
if (newState.id === client.user.id && !newState.channelId && player) {
|
||||||
|
logger.info(`Bot was disconnected from voice channel in guild ${newState.guild.id}. Destroying player.`);
|
||||||
|
player.destroy();
|
||||||
|
return; // Exit early as the player is destroyed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the bot's channel is now empty (excluding the bot itself)
|
||||||
|
const channel = client.channels.cache.get(player.voiceChannel);
|
||||||
|
if (channel && channel.members.size === 1 && channel.members.has(client.user.id)) {
|
||||||
|
logger.info(`Voice channel ${channel.name} (${player.voiceChannel}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`);
|
||||||
|
// Optional: Add a timeout before destroying
|
||||||
|
// setTimeout(() => {
|
||||||
|
// const currentChannel = client.channels.cache.get(player.voiceChannel);
|
||||||
|
// if (currentChannel && currentChannel.members.size === 1) {
|
||||||
|
// player.destroy();
|
||||||
|
// }
|
||||||
|
// }, 60000); // e.g., 1 minute timeout
|
||||||
|
player.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
104
src/index.js
Normal file
104
src/index.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// Load environment variables from .env file
|
||||||
|
require('dotenv').config();
|
||||||
|
const { Client, GatewayIntentBits, Collection } = require('discord.js');
|
||||||
|
const { Manager } = require('erela.js');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Validate essential environment variables
|
||||||
|
if (!process.env.DISCORD_TOKEN) {
|
||||||
|
logger.error('DISCORD_TOKEN is missing in the .env file!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!process.env.LAVALINK_HOST || !process.env.LAVALINK_PORT || !process.env.LAVALINK_PASSWORD) {
|
||||||
|
logger.warn('Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.');
|
||||||
|
// Decide if the bot should exit or continue without music
|
||||||
|
// process.exit(1); // Uncomment to exit if Lavalink is mandatory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new Discord client instance with necessary intents
|
||||||
|
const client = new Client({
|
||||||
|
intents: [
|
||||||
|
GatewayIntentBits.Guilds,
|
||||||
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
|
GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content
|
||||||
|
GatewayIntentBits.MessageContent, // Add if needed for message content
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Erela.js Manager
|
||||||
|
// We need the client to be ready before fully initializing the manager
|
||||||
|
client.manager = new Manager({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
host: process.env.LAVALINK_HOST || 'localhost', // Default host if not set
|
||||||
|
port: parseInt(process.env.LAVALINK_PORT || '2333'), // Default port if not set
|
||||||
|
password: process.env.LAVALINK_PASSWORD || 'youshallnotpass', // Default password if not set
|
||||||
|
secure: process.env.LAVALINK_SECURE === 'true', // Optional: Use true for wss://
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Function to send raw voice data to Discord
|
||||||
|
send(id, payload) {
|
||||||
|
const guild = client.guilds.cache.get(id);
|
||||||
|
if (guild) guild.shard.send(payload);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collections for commands
|
||||||
|
client.commands = new Collection();
|
||||||
|
|
||||||
|
// --- Command Loading ---
|
||||||
|
const commandsPath = path.join(__dirname, 'commands');
|
||||||
|
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const filePath = path.join(commandsPath, file);
|
||||||
|
try {
|
||||||
|
const command = require(filePath);
|
||||||
|
// Set a new item in the Collection with the key as the command name and the value as the exported module
|
||||||
|
if ('data' in command && 'execute' in command) {
|
||||||
|
client.commands.set(command.data.name, command);
|
||||||
|
logger.info(`Loaded command: ${command.data.name}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error loading command at ${filePath}: ${error.message}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Handling ---
|
||||||
|
const eventsPath = path.join(__dirname, 'events');
|
||||||
|
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
|
||||||
|
|
||||||
|
for (const file of eventFiles) {
|
||||||
|
const filePath = path.join(eventsPath, file);
|
||||||
|
const event = require(filePath);
|
||||||
|
if (event.once) {
|
||||||
|
client.once(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
||||||
|
logger.info(`Loaded event ${event.name} (once)`);
|
||||||
|
} else {
|
||||||
|
client.on(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
||||||
|
logger.info(`Loaded event ${event.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Erela.js Event Handling ---
|
||||||
|
// (This is handled within the 'ready' event after manager initialization)
|
||||||
|
logger.info('Erela.js event handling will be attached in the ready event.');
|
||||||
|
|
||||||
|
// Log in to Discord with your client's token
|
||||||
|
client.login(process.env.DISCORD_TOKEN)
|
||||||
|
.then(() => logger.info('Successfully logged in to Discord.'))
|
||||||
|
.catch(error => logger.error(`Failed to log in: ${error.message}`));
|
||||||
|
|
||||||
|
// Basic error handling
|
||||||
|
process.on('unhandledRejection', error => {
|
||||||
|
logger.error('Unhandled promise rejection:', error);
|
||||||
|
});
|
||||||
|
process.on('uncaughtException', error => {
|
||||||
|
logger.error('Uncaught exception:', error);
|
||||||
|
// Optional: exit process on critical uncaught exceptions
|
||||||
|
// process.exit(1);
|
||||||
|
});
|
||||||
98
src/structures/ErelaEvents.js
Normal file
98
src/structures/ErelaEvents.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { EmbedBuilder } = require('discord.js'); // Import EmbedBuilder
|
||||||
|
|
||||||
|
module.exports = (client) => {
|
||||||
|
if (!client || !client.manager) {
|
||||||
|
logger.error("ErelaEvents requires a client with an initialized manager.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.manager
|
||||||
|
.on('nodeConnect', node => logger.info(`Node "${node.options.identifier}" connected.`))
|
||||||
|
.on('nodeError', (node, error) => logger.error(`Node "${node.options.identifier}" encountered an error: ${error.message}`))
|
||||||
|
.on('nodeDisconnect', node => logger.warn(`Node "${node.options.identifier}" disconnected.`))
|
||||||
|
.on('nodeReconnect', node => logger.info(`Node "${node.options.identifier}" reconnecting.`))
|
||||||
|
|
||||||
|
.on('trackStart', (player, track) => {
|
||||||
|
logger.info(`Track started in guild ${player.guild}: ${track.title} requested by ${track.requester?.tag || 'Unknown'}`);
|
||||||
|
|
||||||
|
// Find the text channel associated with the player (if stored)
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor('#0099ff')
|
||||||
|
.setTitle('Now Playing')
|
||||||
|
.setDescription(`[${track.title}](${track.uri})`)
|
||||||
|
.addFields({ name: 'Requested by', value: `${track.requester?.tag || 'Unknown'}`, inline: true })
|
||||||
|
.setTimestamp();
|
||||||
|
if (track.thumbnail) {
|
||||||
|
embed.setThumbnail(track.thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.send({ embeds: [embed] }).catch(e => logger.error(`Failed to send trackStart message: ${e.message}`));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackEnd', (player, track, payload) => {
|
||||||
|
// Only log track end if it wasn't replaced (e.g., by skip or play next)
|
||||||
|
// 'REPLACED' means another track started immediately after this one.
|
||||||
|
if (payload && payload.reason !== 'REPLACED') {
|
||||||
|
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: ${payload.reason}`);
|
||||||
|
} else if (!payload) {
|
||||||
|
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: Unknown/Finished`);
|
||||||
|
}
|
||||||
|
// Optional: Send a message when a track ends naturally
|
||||||
|
// const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
// if (channel && payload && payload.reason === 'FINISHED') {
|
||||||
|
// channel.send(`Finished playing: ${track.title}`);
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackError', (player, track, payload) => {
|
||||||
|
logger.error(`Track error in guild ${player.guild} for track ${track?.title || 'Unknown'}: ${payload.error}`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send(`An error occurred while trying to play: ${track?.title || 'the track'}. Details: ${payload.exception?.message || 'Unknown error'}`).catch(e => logger.error(`Failed to send trackError message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Optionally destroy player or skip track on error
|
||||||
|
// player.stop();
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('trackStuck', (player, track, payload) => {
|
||||||
|
logger.warn(`Track stuck in guild ${player.guild} for track ${track?.title || 'Unknown'}. Threshold: ${payload.thresholdMs}ms`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send(`Track ${track?.title || 'the track'} seems stuck. Skipping...`).catch(e => logger.error(`Failed to send trackStuck message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Skip the track
|
||||||
|
player.stop();
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('queueEnd', (player) => {
|
||||||
|
logger.info(`Queue ended for guild ${player.guild}.`);
|
||||||
|
const channel = client.channels.cache.get(player.textChannel);
|
||||||
|
if (channel) {
|
||||||
|
channel.send('Queue finished. Add more songs!').catch(e => logger.error(`Failed to send queueEnd message: ${e.message}`));
|
||||||
|
}
|
||||||
|
// Optional: Add a timeout before leaving the channel
|
||||||
|
// setTimeout(() => {
|
||||||
|
// if (player.queue.current) return; // Don't leave if something started playing again
|
||||||
|
// player.destroy();
|
||||||
|
// }, 180000); // 3 minutes
|
||||||
|
player.destroy(); // Destroy player immediately when queue ends
|
||||||
|
})
|
||||||
|
|
||||||
|
.on('playerCreate', player => logger.debug(`Player created for guild ${player.guild}`))
|
||||||
|
.on('playerDestroy', player => logger.debug(`Player destroyed for guild ${player.guild}`))
|
||||||
|
.on('playerMove', (player, oldChannel, newChannel) => {
|
||||||
|
if (!newChannel) {
|
||||||
|
logger.info(`Player for guild ${player.guild} disconnected (moved from channel ${oldChannel}). Destroying player.`);
|
||||||
|
player.destroy();
|
||||||
|
} else {
|
||||||
|
logger.debug(`Player for guild ${player.guild} moved from channel ${oldChannel} to ${newChannel}`);
|
||||||
|
player.setVoiceChannel(newChannel); // Update player's voice channel reference
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Erela.js event listeners attached.");
|
||||||
|
};
|
||||||
17
src/utils/logger.js
Normal file
17
src/utils/logger.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(info => `${info.timestamp} ${info.level.toUpperCase()}: ${info.message}`)
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console(),
|
||||||
|
// Optionally add file transport
|
||||||
|
// new winston.transports.File({ filename: 'combined.log' }),
|
||||||
|
// new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
Loading…
x
Reference in New Issue
Block a user