Compare commits

..

2 Commits

24 changed files with 714 additions and 276 deletions

31
.gitignore vendored
View File

@ -1,12 +1,6 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
/target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# Node modules
node_modules/
dist/
# dotenv environment variables
.env
@ -14,18 +8,13 @@ Cargo.lock
# VSCode settings
.vscode/
# macOS
# Mac system files
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk
# Lockfiles
pnpm-lock.yaml
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Logs
npm-debug.log*
logs/
*.log

View File

@ -1,16 +0,0 @@
[package]
name = "discord-music-bot"
version = "1.0.0"
edition = "2021"
[dependencies]
serenity = { version = "0.12", features = ["client", "gateway", "cache", "model", "http", "builder", "voice", "rustls_backend", "application"] }
lavalink-rs = { version = "0.14", features = ["serenity", "tungstenite-rustls-native-roots"] }
tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
dotenv = "0.15"
futures = "0.3"
inventory = { version = "0.3", features = ["proc-macro"] }
url = "2.4"

61
deploy-commands.js Normal file
View 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
View 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
View 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}`);
}
}
},
};

View File

@ -1,16 +0,0 @@
use super::SlashCommand;
use serenity::builder::CreateCommand;
use serenity::prelude::Context;
use serenity::model::application::interaction::Interaction;
use anyhow::Result;
inventory::submit! {
SlashCommand {
name: "join",
register: |c| c.name("join").description("Joins your voice channel"),
handler: |ctx: Context, interaction: Interaction| Box::pin(async move {
// TODO: Implement join logic (e.g., move bot to user voice channel)
Ok(())
}),
}
}

39
src/commands/leave.js Normal file
View 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}`));
}
},
};

View File

@ -1,16 +0,0 @@
use super::SlashCommand;
use serenity::builder::CreateApplicationCommand;
use serenity::prelude::Context;
use serenity::model::interactions::Interaction;
use anyhow::Result;
inventory::submit! {
SlashCommand {
name: "leave",
register: |c| c.name("leave").description("Leaves the voice channel"),
handler: |ctx: Context, interaction: Interaction| Box::pin(async move {
// TODO: Implement leave logic (e.g., disconnect from voice channel)
Ok(())
}),
}
}

View File

@ -1,26 +0,0 @@
use serenity::builder::CreateCommand;
use serenity::prelude::Context;
use serenity::model::interactions::Interaction;
use anyhow::Result;
/// Defines a slash command with its registration and handler.
pub struct SlashCommand {
pub name: &'static str,
pub register: fn(&mut CreateCommand) -> &mut CreateCommand,
pub handler: fn(Context, Interaction) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>>,
}
inventory::collect!(SlashCommand);
/// Returns all registered slash commands.
pub fn get_slash_commands() -> Vec<&'static SlashCommand> {
inventory::iter::<SlashCommand>
.into_iter()
.collect()
}
// Register individual command modules
pub mod ping;
pub mod join;
pub mod play;
pub mod leave;

15
src/commands/ping.js Normal file
View 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`);
},
};

View File

@ -1,16 +0,0 @@
use super::SlashCommand;
use serenity::builder::CreateCommand;
use serenity::prelude::Context;
use serenity::model::application::interaction::Interaction;
use anyhow::Result;
inventory::submit! {
SlashCommand {
name: "ping",
register: |c: &mut CreateCommand| c.name("ping").description("Replies with Pong!"),
handler: |ctx: Context, interaction: Interaction| Box::pin(async move {
// TODO: Implement ping logic (e.g., respond with "Pong!")
Ok(())
}),
}
}

157
src/commands/play.js Normal file
View 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();
// }
}
},
};

View File

@ -1,26 +0,0 @@
use super::SlashCommand;
use serenity::builder::CreateApplicationCommand;
use serenity::model::interactions::application_command::ApplicationCommandOptionType;
use serenity::prelude::Context;
use serenity::model::interactions::Interaction;
use anyhow::Result;
inventory::submit! {
SlashCommand {
name: "play",
register: |c| {
c.name("play")
.description("Plays audio from a given URL")
.create_option(|o| {
o.name("url")
.description("Track URL to play")
.kind(ApplicationCommandOptionType::String)
.required(true)
})
},
handler: |ctx: Context, interaction: Interaction| Box::pin(async move {
// TODO: Implement play logic (e.g., fetch URL argument and instruct Lavalink to play)
Ok(())
}),
}
}

View 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
View 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.
},
};

View 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();
}
},
};

View File

@ -1,49 +0,0 @@
use serenity::async_trait;
use serenity::model::gateway::Ready;
use serenity::model::application::interaction::{Interaction, InteractionResponseType};
use serenity::model::channel::VoiceState;
use serenity::prelude::*;
use anyhow::Result;
use tracing::info;
use crate::commands::get_slash_commands;
use crate::state::LavalinkClientKey;
pub struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
info!("{} is connected!", ready.user.name);
let commands = get_slash_commands();
for cmd in commands {
match ctx.http.create_global_application_command(|c| (cmd.register)(c)).await {
Ok(_) => info!("Registered slash command: {}", cmd.name),
Err(err) => info!("Failed to register {}: {:?}", cmd.name, err),
}
}
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(ref command) = interaction {
if let Err(err) = command.defer(&ctx.http).await {
info!("Failed to defer response: {:?}", err);
}
for cmd in get_slash_commands() {
if cmd.name == command.data.name {
if let Err(err) = (cmd.handler)(ctx.clone(), interaction.clone()).await {
info!("Command error {}: {:?}", cmd.name, err);
}
}
}
}
}
async fn voice_state_update(&self, ctx: Context, old: Option<VoiceState>, new: VoiceState) {
if let Some(guild) = new.guild_id {
let data_read = ctx.data.read().await;
if let Some(lavalink) = data_read.get::<LavalinkClientKey>() {
lavalink.on_voice_state_update(&new).await;
}
}
}
}

104
src/index.js Normal file
View 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);
});

View File

@ -1,37 +0,0 @@
// src/lavalink_handler.rs
use anyhow::Result;
use tracing::info;
use async_trait::async_trait;
use lavalink_rs::prelude::{LavalinkClient, LavalinkClientBuilder, EventHandler, TrackStartEvent, TrackEndEvent};
use crate::utils::env_var;
/// Handles Lavalink events such as track start and end.
pub struct LavalinkHandler;
#[async_trait]
impl EventHandler for LavalinkHandler {
async fn track_start(&self, _client: &LavalinkClient, event: &TrackStartEvent) {
info!("Track started: {}", event.track);
}
async fn track_end(&self, _client: &LavalinkClient, event: &TrackEndEvent) {
info!("Track ended: {}", event.track);
}
// TODO: Add more event handlers as needed
}
/// Initializes and returns a Lavalink client.
pub async fn create_lavalink_client() -> Result<LavalinkClient> {
let host = env_var("LAVALINK_HOST");
let port = env_var("LAVALINK_PORT").parse::<u16>()?;
let password = env_var("LAVALINK_PASSWORD");
let url = format!("ws://{}:{}", host, port);
let builder = LavalinkClientBuilder::new(&url)
.password(&password)
.event_handler(LavalinkHandler);
let client = builder.build().await?;
Ok(client)
}

View File

@ -1,38 +0,0 @@
// src/main.rs
mod handler;
mod lavalink_handler;
mod state;
mod utils;
mod commands;
use anyhow::Result;
use dotenv::dotenv;
use std::env;
use tracing_subscriber;
use serenity::prelude::TypeMapKey;
use serenity::Client;
use crate::lavalink_handler::create_lavalink_client;
#[tokio::main]
async fn main() -> Result<()> {
dotenv().ok();
tracing_subscriber::fmt().with_env_filter("info").init();
let token = env::var("DISCORD_TOKEN")?;
// Initialize Lavalink client
let lavalink_client = create_lavalink_client().await?;
let mut client = Client::builder(&token)
.event_handler(handler::Handler)
.await?;
{
let mut data = client.data.write().await;
data.insert::<state::LavalinkClientKey>(lavalink_client);
}
client.start().await?;
Ok(())
}

View File

@ -1,8 +0,0 @@
// src/state.rs
use serenity::prelude::TypeMapKey;
use lavalink_rs::prelude::LavalinkClient;
pub struct LavalinkClientKey;
impl TypeMapKey for LavalinkClientKey {
type Value = LavalinkClient;
}

View 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.");
};

View File

@ -1,7 +0,0 @@
// src/utils.rs
use std::env;
/// Retrieves an environment variable or panics if it's not set.
pub fn env_var(key: &str) -> String {
env::var(key).expect(&format!("Environment variable {} not set", key))
}

17
src/utils/logger.js Normal file
View 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;