diff --git a/deploy-commands.js b/deploy-commands.js new file mode 100644 index 0000000..f4ec6e4 --- /dev/null +++ b/deploy-commands.js @@ -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); + } +})(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d99eab --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/commands/join.js b/src/commands/join.js new file mode 100644 index 0000000..4e5ced3 --- /dev/null +++ b/src/commands/join.js @@ -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}`); + } + } + }, +}; diff --git a/src/commands/leave.js b/src/commands/leave.js new file mode 100644 index 0000000..e115b01 --- /dev/null +++ b/src/commands/leave.js @@ -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}`)); + } + }, +}; diff --git a/src/commands/ping.js b/src/commands/ping.js new file mode 100644 index 0000000..9f51562 --- /dev/null +++ b/src/commands/ping.js @@ -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`); + }, +}; diff --git a/src/commands/play.js b/src/commands/play.js new file mode 100644 index 0000000..cc11c50 --- /dev/null +++ b/src/commands/play.js @@ -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(); + // } + } + }, +}; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js new file mode 100644 index 0000000..c9f7085 --- /dev/null +++ b/src/events/interactionCreate.js @@ -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}`); + } + } + }, +}; diff --git a/src/events/ready.js b/src/events/ready.js new file mode 100644 index 0000000..18e9acf --- /dev/null +++ b/src/events/ready.js @@ -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. + }, +}; diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js new file mode 100644 index 0000000..98bcb2d --- /dev/null +++ b/src/events/voiceStateUpdate.js @@ -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(); + } + }, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0cafc39 --- /dev/null +++ b/src/index.js @@ -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); +}); diff --git a/src/structures/ErelaEvents.js b/src/structures/ErelaEvents.js new file mode 100644 index 0000000..903c042 --- /dev/null +++ b/src/structures/ErelaEvents.js @@ -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."); +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..b648af4 --- /dev/null +++ b/src/utils/logger.js @@ -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;