diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8ba6f52 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "discord-music-bot-lavalink-rs" +version = "0.1.0" +edition = "2021" + +[dependencies] +serenity = { version = "0.12", features = ["builder", "cache", "client", "gateway", "model", "framework", "standard_framework", "http", "rustls_backend", "voice", "collector", "interactions"] } +lavalink-rs = { version = "0.14", features = ["serenity", "tungstenite-rustls-native-roots"] } +tokio = { version = "1.44", features = ["full"] } +dotenv = "0.15" +url = "2.5" +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1.0" +# Add inventory for command registration +inventory = "0.3" +# Add futures for boxed async traits +futures = "0.3" \ No newline at end of file diff --git a/src/commands/join.rs b/src/commands/join.rs new file mode 100644 index 0000000..13034b8 --- /dev/null +++ b/src/commands/join.rs @@ -0,0 +1,57 @@ +// src/commands/join.rs + +use super::{CommandHandler, SlashCommand}; // Import from parent mod.rs +use crate::utils::get_voice_state; // Import utility +use anyhow::{Context as _, Result}; +use lavalink_rs::LavalinkClient; +use serenity::{ + builder::{CreateApplicationCommandOption, EditInteractionResponse}, // Need EditInteractionResponse + client::Context, + model::prelude::{ + interaction::application_command::ApplicationCommandInteraction, + Id // Import Id directly if needed, though GuildId/ChannelId are often used + }, +}; +use tracing::instrument; +use futures::future::BoxFuture; // Import BoxFuture + +// Command metadata and registration +inventory::submit! { + SlashCommand { + name: "join", + description: "Makes the bot join your current voice channel.", + options: || Vec::new(), // No options for /join + handler: handle_join, + } +} + +// The actual logic for the /join command +#[instrument(skip(ctx, interaction, lavalink))] +fn handle_join<'a>( + ctx: &'a Context, + interaction: &'a ApplicationCommandInteraction, + lavalink: &'a LavalinkClient, +) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let guild_id = interaction.guild_id.context("Command must be used in a guild")?; + let user_id = interaction.user.id; + + // Find the user's voice channel using the utility function + let voice_state = get_voice_state(ctx, guild_id, user_id).await + .context("Failed to get your voice state. Are you in a channel?")?; + + let channel_id = voice_state.channel_id + .context("You are not currently in a voice channel.")?; + + // Request Lavalink create a session (actual connection happens via gateway events) + lavalink.create_session(guild_id, channel_id).await?; + + // Edit the deferred response to confirm action + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new() + .content(format!("Joining <#{}>...", channel_id)) + ).await?; + + Ok(()) + }) +} \ No newline at end of file diff --git a/src/commands/leave.rs b/src/commands/leave.rs new file mode 100644 index 0000000..26ca0c1 --- /dev/null +++ b/src/commands/leave.rs @@ -0,0 +1,49 @@ +// src/commands/leave.rs + +use super::{CommandHandler, SlashCommand}; +use anyhow::{Context as _, Result}; +use lavalink_rs::LavalinkClient; +use serenity::{ + builder::{CreateApplicationCommandOption, EditInteractionResponse}, + client::Context, + model::prelude::interaction::application_command::ApplicationCommandInteraction, +}; +use tracing::instrument; +use futures::future::BoxFuture; + +inventory::submit! { + SlashCommand { + name: "leave", + description: "Makes the bot leave the current voice channel.", + options: || Vec::new(), // No options + handler: handle_leave, + } +} + +#[instrument(skip(ctx, interaction, lavalink))] +fn handle_leave<'a>( + ctx: &'a Context, + interaction: &'a ApplicationCommandInteraction, + lavalink: &'a LavalinkClient, +) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let guild_id = interaction.guild_id.context("Command must be used in a guild")?; + + // Check if connected before trying to leave + if lavalink.get_player(guild_id).is_none() { + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new().content("Bot is not in a voice channel.") + ).await?; + return Ok(()); // Not an error, just inform user + } + + // Destroy the Lavalink session for this guild + lavalink.destroy_session(guild_id).await?; + + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new().content("Left the voice channel.") + ).await?; + + Ok(()) + }) +} \ No newline at end of file diff --git a/src/commands/play.rs b/src/commands/play.rs new file mode 100644 index 0000000..ce7ff05 --- /dev/null +++ b/src/commands/play.rs @@ -0,0 +1,168 @@ +// src/commands/play.rs + +use super::{CommandHandler, SlashCommand}; +use crate::utils::get_voice_state; // Needed if join logic is integrated +use anyhow::{Context as _, Result}; +use lavalink_rs::{ + model::search::{SearchResult, SearchType}, + LavalinkClient +}; +use serenity::{ + builder::{CreateApplicationCommandOption, EditInteractionResponse}, + client::Context, + model::prelude::{ + interaction::application_command::{ + ApplicationCommandInteraction, CommandDataOptionValue, + ApplicationCommandOptionType, + }, + Id, + }, +}; +use tracing::{info, instrument}; +use url::Url; +use futures::future::BoxFuture; +use tokio::time::Duration; // Needed for sleep if join logic integrated + +// Command metadata and registration +inventory::submit! { + SlashCommand { + name: "play", + description: "Plays a song from a URL or search query.", + options: play_options, // Function pointer to build options + handler: handle_play, + } +} + +// Function to define options for the /play command +fn play_options() -> Vec { + vec![ + CreateApplicationCommandOption::new( + ApplicationCommandOptionType::String, + "query", + "The URL or search query for the song." + ) + .required(true) + ] +} + + +// Helper to join channel if not already in one (avoids duplicating join logic) +// Note: This is slightly simplified, might need more robust error handling from join +async fn ensure_connected( + ctx: &Context, + interaction: &ApplicationCommandInteraction, + guild_id: serenity::model::id::GuildId, + lavalink: &LavalinkClient, +) -> Result<()> { + if lavalink.get_player(guild_id).is_some() { + return Ok(()); // Already connected + } + + info!("Play command used but bot not connected. Attempting auto-join."); + let user_id = interaction.user.id; + let voice_state = get_voice_state(ctx, guild_id, user_id).await + .context("Bot not connected & failed to get your voice state. Are you in a channel?")?; + let channel_id = voice_state.channel_id + .context("Bot not connected & you're not in a voice channel. Use /join first.")?; + + lavalink.create_session(guild_id, channel_id).await?; + // Give a moment for connection events to process + tokio::time::sleep(Duration::from_secs(1)).await; + + // Check if connection succeeded + lavalink.get_player(guild_id) + .context("Failed to establish voice connection after attempting to join.")?; + + info!("Auto-join successful for play command."); + Ok(()) +} + + +// The actual logic for the /play command +#[instrument(skip(ctx, interaction, lavalink))] +fn handle_play<'a>( + ctx: &'a Context, + interaction: &'a ApplicationCommandInteraction, + lavalink: &'a LavalinkClient, +) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let guild_id = interaction.guild_id.context("Command must be used in a guild")?; + + // Ensure the bot is connected, joining if necessary + ensure_connected(ctx, interaction, guild_id, lavalink).await?; + + // Get the query string from the interaction options + let query = interaction.data.options.iter() + .find(|opt| opt.name == "query") + .and_then(|opt| opt.value.as_ref()) + .and_then(|v| v.as_str()) // Use as_str for simplicity + .context("Missing 'query' option")?; + + info!("Searching for: {}", query); + + // Determine search type + let search_type = if Url::parse(query).is_ok() { + SearchType::Track(query.to_string()) + } else { + SearchType::YouTube(query.to_string()) + }; + + // Perform the search + let search_result = lavalink.search_tracks(search_type).await?; + + // Process results (simplified from previous version for brevity) + let (tracks_to_play, response_prefix) = match search_result { + SearchResult::Track(track) => { + info!("Found track: {}", track.info.title); + (vec![track], "Now playing:".to_string()) + } + SearchResult::Playlist(playlist) => { + info!("Found playlist: {} ({} tracks)", playlist.info.name, playlist.tracks.len()); + (playlist.tracks, format!("Queued playlist **{}**: Playing first track:", playlist.info.name)) + } + SearchResult::Search(tracks) => { + if let Some(track) = tracks.first() { + info!("Found search result: {}", track.info.title); + (vec![track.clone()], "Now playing:".to_string()) + } else { (vec![], String::new()) } // No tracks found + } + SearchResult::Empty => (vec![], String::new()), // No tracks found + SearchResult::LoadFailed(e) => { + return Err(anyhow::anyhow!("Failed to load track: {}", e.message)); + } + }; + + if tracks_to_play.is_empty() { + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new().content(format!("No results found for: `{}`", query)) + ).await?; + return Ok(()); + } + + // Get the player (should exist after ensure_connected) + let player = lavalink.get_player(guild_id) + .context("Player disappeared unexpectedly after connecting.")?; + + + // Play first track, queue the rest + let first_track = tracks_to_play.first().unwrap(); // Safe due to is_empty check + player.play_track(&first_track.track).await?; + + let mut final_response = format!("{} **{}** by **{}**", + response_prefix, first_track.info.title, first_track.info.author); + + if tracks_to_play.len() > 1 { + for track in tracks_to_play.iter().skip(1) { + player.queue(&track.track).await?; + } + final_response.push_str(&format!("\n...and queued {} other tracks.", tracks_to_play.len() - 1)); + } + + // Update interaction response + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new().content(final_response) + ).await?; + + Ok(()) + }) +} \ No newline at end of file diff --git a/src/commands/skip.rs b/src/commands/skip.rs new file mode 100644 index 0000000..7519318 --- /dev/null +++ b/src/commands/skip.rs @@ -0,0 +1,45 @@ +// src/commands/skip.rs + +use super::{CommandHandler, SlashCommand}; +use anyhow::{Context as _, Result}; +use lavalink_rs::LavalinkClient; +use serenity::{ + builder::{CreateApplicationCommandOption, EditInteractionResponse}, + client::Context, + model::prelude::interaction::application_command::ApplicationCommandInteraction, +}; +use tracing::instrument; +use futures::future::BoxFuture; + +inventory::submit! { + SlashCommand { + name: "skip", + description: "Skips the currently playing song.", + options: || Vec::new(), // No options + handler: handle_skip, + } +} + +#[instrument(skip(ctx, interaction, lavalink))] +fn handle_skip<'a>( + ctx: &'a Context, + interaction: &'a ApplicationCommandInteraction, + lavalink: &'a LavalinkClient, +) -> BoxFuture<'a, Result<()>> { + Box::pin(async move { + let guild_id = interaction.guild_id.context("Command must be used in a guild")?; + + // Get the player for the guild + let player = lavalink.get_player(guild_id) + .context("Bot is not connected to a voice channel in this guild.")?; + + // Stop the current track (Lavalink plays next if available) + player.stop().await?; + + interaction.edit_original_response(&ctx.http, + EditInteractionResponse::new().content("Skipped track.") + ).await?; + + Ok(()) + }) +} \ No newline at end of file diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..a4420b6 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,199 @@ +// src/handler.rs + +use crate::{ + state::BotState, + commands::SlashCommand, // Import the command definition struct + utils::get_voice_state, +}; +use serenity::{ + async_trait, + builder::{ + CreateApplicationCommand, // Need this for registration + CreateInteractionResponse, CreateInteractionResponseData, + EditInteractionResponse + }, + client::{Context, EventHandler}, + gateway::ActivityData, + model::{ + gateway::{Ready, Activity}, + id::{GuildId, ChannelId}, + prelude::interaction::{Interaction, application_command::ApplicationCommandInteraction}, + voice::VoiceState, + }, +}; +use lavalink_rs::LavalinkClient; +use tokio::sync::Mutex; +use tokio::time::Duration; +use tracing::{error, info, instrument, warn}; +use std::sync::Arc; + +// --- Event Handler --- +pub struct Handler { + pub state: Arc>, +} + +// Helper methods (check_and_handle_auto_leave remains the same as previous version) +impl Handler { + #[instrument(skip(self, ctx, lavalink))] + async fn check_and_handle_auto_leave( + &self, + ctx: &Context, + guild_id: GuildId, + bot_channel_id: ChannelId, + lavalink: &LavalinkClient, + ) { + let members_in_channel = ctx.cache.guild(guild_id) + .await + .map(|guild| { + guild.voice_states.iter() + .filter(|(_, vs)| vs.channel_id == Some(bot_channel_id)) + .count() + }); + + let Some(1) = members_in_channel else { return; }; + + info!("Bot potentially alone in channel {}, waiting...", bot_channel_id); + tokio::time::sleep(Duration::from_secs(5)).await; + + let Some(player_after_delay) = lavalink.get_player(guild_id) else { return; }; + if player_after_delay.connected_channel_id != Some(bot_channel_id) { return; }; + + let members_after_delay = ctx.cache.guild(guild_id) + .await + .map(|guild| { + guild.voice_states.iter() + .filter(|(_, vs)| vs.channel_id == Some(bot_channel_id)) + .count() + }); + + let Some(1) = members_after_delay else { return; }; + + info!("Bot confirmed alone in channel {}, leaving.", bot_channel_id); + if let Err(e) = lavalink.destroy_session(guild_id).await { + error!("Failed auto-leave {}: {}", bot_channel_id, e); + } else { + info!("Successfully auto-left {}", bot_channel_id); + } + } +} + + +#[async_trait] +impl EventHandler for Handler { + /// Called when the bot is ready. Registers slash commands. + #[instrument(skip(self, ctx, ready))] + async fn ready(&self, ctx: Context, ready: Ready) { + info!("{} is connected!", ready.user.name); + + // --- Command Registration using inventory --- + info!("Registering global slash commands..."); + let command_build_results = serenity::model::application::command::Command::set_global_application_commands( + &ctx.http, + |commands_builder| { + for cmd in inventory::iter:: { + commands_builder.create_application_command(|command| { + command.name(cmd.name).description(cmd.description); + // Build options using the provided function + let options = (cmd.options)(); + for opt in options { + command.add_option(opt); // Use add_option + } + command // Return the command builder + }); + } + commands_builder // Return the main builder + }, + ) + .await; + + match command_build_results { + Ok(registered_commands) => info!("Successfully registered {} global commands.", registered_commands.len()), + Err(err) => error!("Failed to register global commands: {:?}", err), + } + + ctx.set_activity(ActivityData::custom("🎶 Music Time")).await; + } + + + /// Called for interactions. Dispatches commands. + #[instrument(skip(self, ctx))] + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + let Interaction::ApplicationCommand(command) = interaction else { return; }; + + info!("Received command: /{}", command.data.name); + + if let Err(e) = command.create_response(&ctx.http, + CreateInteractionResponse::Defer( + CreateInteractionResponseData::new().ephemeral(false) + ) + ).await { + error!("Failed to defer interaction response: {}", e); + return; + } + + let Some(guild_id) = command.guild_id else { + warn!("Guild command '/{}' used outside of a guild.", command.data.name); + let _ = command.edit_original_response(&ctx.http, + EditInteractionResponse::new().content("This command only works in servers.") + ).await; + return; + }; + + // --- Command Dispatch using inventory --- + let command_name = command.data.name.as_str(); + let mut command_found = false; + let mut command_result: Option> = None; + + { // Scope for state lock + let state_guard = self.state.lock().await; + let lavalink = &state_guard.lavalink; + + for cmd_def in inventory::iter:: { + if cmd_def.name == command_name { + info!("Dispatching command: {}", cmd_def.name); + command_found = true; + // Call the registered handler function + command_result = Some((cmd_def.handler)(&ctx, &command, lavalink).await); + break; // Found and handled + } + } + } // state_guard lock released + + // --- Handle Dispatch Result --- + if !command_found { + error!("Handler not found for received command: {}", command_name); + command_result = Some(Err(anyhow::anyhow!("Unknown command '/{}'", command_name))); + } + + // Handle the result (error logging/reporting) + if let Some(Err(e)) = command_result { + error!("Error handling command '/{}': {:?}", command_name, e); + if let Err(edit_err) = command.edit_original_response(&ctx.http, + EditInteractionResponse::new().content(format!("An error occurred: {}", e)) + ).await { + error!("Failed to send error response for '/{}': {}", command_name, edit_err); + } + } else if command_found { + info!("Successfully handled command '/{}'", command_name); + } + } + + /// Called for voice state changes. Handles Lavalink updates and auto-leave. + #[instrument(skip(self, ctx))] + async fn voice_state_update(&self, ctx: Context, _old: Option, new: VoiceState) { + let state_guard = self.state.lock().await; + let lavalink = &state_guard.lavalink; + + // Always forward to lavalink-rs + lavalink.handle_voice_state_update(&ctx.cache, &new).await; + + // Auto-Leave Logic (simplified) + let Some(guild_id) = new.guild_id else { return; }; + let Some(player) = lavalink.get_player(guild_id) else { return; }; + let Some(bot_channel_id) = player.connected_channel_id else { return; }; + + // Drop the lock before calling the helper which might sleep + drop(state_guard); + self.check_and_handle_auto_leave(&ctx, guild_id, bot_channel_id, lavalink).await; + } +} // End impl EventHandler \ No newline at end of file diff --git a/src/lavalink_handler.rs b/src/lavalink_handler.rs new file mode 100644 index 0000000..2b39259 --- /dev/null +++ b/src/lavalink_handler.rs @@ -0,0 +1,85 @@ +// src/lavalink_handler.rs + +use serenity::http::Http; // Import Http for sending messages +use lavalink_rs::gateway::{NodeEvent, LavalinkEventHandler as LavalinkEventHandlerRs}; +use tracing::{error, info, instrument}; +use async_trait::async_trait; // Needed to implement async trait methods +use std::sync::Arc; // Needed for Arc + + +// --- lavalink-rs Event Handler --- +// This handler receives events directly from the connected Lavalink server. +// It allows reacting to playback events like track start/end. +pub struct MyLavalinkEventHandlerRs { + pub http: Arc, // Hold the Http client to send messages to Discord + // Add state if needed, e.g., channel IDs for announcements per guild. +} + +#[async_trait] +impl LavalinkEventHandlerRs for MyLavalinkEventHandlerRs { + // The main handler function for all Lavalink NodeEvents. + #[instrument(skip(self))] // Adds tracing to the handler + async fn handle(&self, event: NodeEvent) { + // You can uncomment this for verbose logging of all events: + // info!("Received LavaLink event: {:?}", event); + + match event { + // Handle specific event types + NodeEvent::TrackStart { guild_id, track, .. } => { + info!( + "Track started on guild {}: {}", + guild_id, + track.info.title + ); + // Example: send a message announcing the track. + // This requires knowing which channel to send it to, often + // stored per guild in the BotState's announcement_channels. + // Accessing BotState would require passing it here or using a global static. + // let channel_id = { /* logic to get channel_id from guild_id using self.http or shared state */ }; + // if let Some(channel_id) = channel_id { + // if let Err(e) = ChannelId(channel_id).say(&self.http, + // format!("Now playing: **{}** by **{}**", track.info.title, track.info.author) + // ).await { + // error!("Failed to send track start message: {}", e); + // } + // } + } + NodeEvent::TrackEnd { guild_id, track, reason, .. } => { + info!( + "Track ended on guild {}: {}, reason: {:?}", + guild_id, + track.info.title, + reason + ); + // Logic for track end, e.g., cleaning up state if not using Lavalink queues + } + NodeEvent::TrackException { guild_id, track, error, .. } => { + error!( + "Track exception on guild {}: {}, error: {}", + guild_id, + track.info.title, + error + ); + // Notify the channel about the exception + } + NodeEvent::TrackStuck { guild_id, track, threshold_ms, .. } => { + error!( + "Track stuck on guild {}: {}, threshold: {}ms", + guild_id, + track.info.title, + threshold_ms + ); + // Notify the channel about the stuck track + } + NodeEvent::WebSocketOpen { node, .. } => { + info!("Lavalink WebSocket opened: {}", node); + } + NodeEvent::WebSocketClosed { node, code, reason, .. } => { + error!("Lavalink WebSocket closed: {}, Code: {:?}, Reason: {}", node, code, reason); + } + // Ignore these common events to avoid log spam + NodeEvent::Ready { .. } | NodeEvent::Stats { .. } => { /* Ignore */ } + NodeEvent::PlayerUpdate { .. } => { /* Ignore */ } + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bf4d616 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,123 @@ +// src/main.rs + +// Declare the modules used in this crate +mod handler; +mod state; +mod commands; +mod lavalink_handler; +mod utils; + +// Import necessary types from our modules +use handler::Handler; +use state::BotState; +use lavalink_handler::MyLavalinkEventHandlerRs; + +// Import necessary types from external crates +use serenity::client::Client; +use serenity::prelude::GatewayIntents; // Needed to subscribe to events +use lavalink_rs::LavalinkClient; +use tokio::sync::Mutex; +use tracing::{error, info, instrument}; +use std::sync::Arc; // For shared ownership +use std::env; // To read environment variables + +// The entry point of the application +#[tokio::main] +#[instrument] // Adds tracing to the main function +async fn main() { + // Initialize tracing for logging output + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) // Set logging level + .init(); + + // Load environment variables from .env file + dotenv::dotenv().ok(); + + // Get credentials from environment variables + let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set"); + let lavalink_host = env::var("LAVALINK_HOST") + .expect("LAVALINK_HOST not set"); + let lavalink_port = env::var("LAVALINK_PORT") + .expect("LAVALINK_PORT not set") + .parse::() + .expect("Invalid LAVALINK_PORT"); + let lavalink_password = env::var("LAVALINK_PASSWORD") + .expect("LAVALINK_PASSWORD not set"); + + // Define the gateway intents needed for the bot + // GUILDS: To receive guild information for commands and cache + // GUILD_VOICE_STATES: Crucial for receiving voice state updates + let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES; + + // Build the Serenity client. Need the await here because of the async builder. + let mut serenity_client = Client::builder(&token, intents) + .await + .expect("Error creating serenity client"); + + // Clone the Arc client from Serenity. We need this to be able to send + // HTTP requests (like sending messages) from places that don't have the full Context, + // such as the Lavalink event handler. + let http = serenity_client.cache_and_http.http.clone(); + + // Get the bot's user ID from the cache. This is needed to initialize the Lavalink client. + let bot_user_id = serenity_client + .cache_and_http + .cache + .current_user_id() + .await + .expect("Failed to get bot user ID from cache"); + + // Create the Lavalink client builder + let mut lavalink_client_builder = LavalinkClient::builder(bot_user_id); + + // Set Lavalink node details + lavalink_client_builder + .set_host(lavalink_host) + .set_port(lavalink_port) + .set_password(lavalink_password); + + // Create and set the custom Lavalink event handler, passing the http client + let lavalink_event_handler = Arc::new(MyLavalinkEventHandlerRs { + http: http.clone() // Clone Http again for the lavalink event handler + }); + lavalink_client_builder.set_event_handler(lavalink_event_handler); + + // Build the Lavalink client + let lavalink_client = lavalink_client_builder.build(); + + // Create and store the shared state (BotState) + let bot_state = Arc::new(Mutex::new(BotState { + lavalink: lavalink_client.clone(), // Clone lavalink client for the handler struct + http: http, // Store the Http client clone in the state + // announcement_channels: Mutex::new(HashMap::new()), // Optional: for announcements + })); + + // Set the Serenity event handler, passing the shared state + serenity_client.event_handler(Handler { state: bot_state }); + + // Spawn the lavalink-rs client's main run task. This task will manage the + // WebSocket connection to the Lavalink server and process its events. + let lavalink_task = tokio::spawn(async move { + info!("Starting lavalink-rs client task..."); + if let Err(why) = lavalink_client.run().await { + error!("Lavalink client task error: {:?}", why); + } + info!("Lavalink client task stopped."); + }); + + + // Start the Serenity client gateway. This connects the bot to Discord + // and begins receiving events. This call is blocking until the client stops. + info!("Starting serenity client gateway..."); + if let Err(why) = serenity_client.start().await { + error!("Serenity client gateway error: {:?}", why); + } + info!("Serenity client gateway stopped."); + + + // Wait for the lavalink task to finish. + // In a real bot, this task should ideally never finish unless there's a critical error. + let _ = lavalink_task.await; + + info!("Bot shut down."); +} \ No newline at end of file diff --git a/src/mod.rs b/src/mod.rs new file mode 100644 index 0000000..7bce3d8 --- /dev/null +++ b/src/mod.rs @@ -0,0 +1,41 @@ +// src/commands/mod.rs + +use anyhow::Result; +use lavalink_rs::LavalinkClient; +use serenity::{ + builder::CreateApplicationCommandOption, // Correct import path + client::Context, + model::prelude::interaction::application_command::ApplicationCommandInteraction, +}; +use std::future::Future; +use std::pin::Pin; +use futures::future::BoxFuture; // Use BoxFuture for convenience + +// --- Command Definition --- + +// Type alias for the async command handler function signature +pub type CommandHandler = for<'a> fn( + &'a Context, + &'a ApplicationCommandInteraction, + &'a LavalinkClient, +) -> BoxFuture<'a, Result<()>>; + +// Structure to hold all information needed to register and execute a command +pub struct SlashCommand { + pub name: &'static str, + pub description: &'static str, + // Function to build command options (lazily evaluated) + pub options: fn() -> Vec, + // The async handler function + pub handler: CommandHandler, +} + +// Tell `inventory` to collect all `SlashCommand` structs submitted globally. +inventory::collect!(SlashCommand); + +// --- Submodule Declarations --- +// Declare all files in the commands/ directory as submodules +pub mod join; +pub mod leave; +pub mod play; +pub mod skip; \ No newline at end of file diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..6990f3d --- /dev/null +++ b/src/state.rs @@ -0,0 +1,17 @@ +// src/state.rs + +use lavalink_rs::LavalinkClient; +use serenity::{http::Http, model::id::{GuildId, ChannelId}}; +use tokio::sync::Mutex; +use std::{sync::Arc, collections::HashMap}; + +// BotState holds shared data accessible by different parts of the bot, +// like the Lavalink client and the Serenity HTTP client. +pub struct BotState { + pub lavalink: LavalinkClient, + // Need Http client to send messages from areas that don't have Context, + // like the Lavalink event handler. + pub http: Arc, + // Optional: Store announcement channels per guild for track announcements. + // pub announcement_channels: Mutex>, +} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..817e64f --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,25 @@ +// src/utils.rs + +use serenity::{ + client::Context, + model::{ + id::{GuildId, UserId}, + voice::VoiceState, + }, +}; +use tracing::instrument; + + +// Helper function to find the voice state of a user in a specific guild +// using the cached data. Returns None if the user is not in a voice channel +// in that guild, or if the guild is not in the cache. +#[instrument(skip(ctx))] +pub async fn get_voice_state( + ctx: &Context, + guild_id: GuildId, + user_id: UserId +) -> Option { + ctx.cache.guild(guild_id).await + // Get the guild from cache, then look up the user's voice state in that guild + .and_then(|guild| guild.voice_states.get(&user_id).cloned()) +} \ No newline at end of file