Initial code upload
This commit is contained in:
parent
fe4f0aec41
commit
44170c8ae7
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@ -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"
|
||||
57
src/commands/join.rs
Normal file
57
src/commands/join.rs
Normal file
@ -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(())
|
||||
})
|
||||
}
|
||||
49
src/commands/leave.rs
Normal file
49
src/commands/leave.rs
Normal file
@ -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(())
|
||||
})
|
||||
}
|
||||
168
src/commands/play.rs
Normal file
168
src/commands/play.rs
Normal file
@ -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<CreateApplicationCommandOption> {
|
||||
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(())
|
||||
})
|
||||
}
|
||||
45
src/commands/skip.rs
Normal file
45
src/commands/skip.rs
Normal file
@ -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(())
|
||||
})
|
||||
}
|
||||
199
src/handler.rs
Normal file
199
src/handler.rs
Normal file
@ -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<Mutex<BotState>>,
|
||||
}
|
||||
|
||||
// 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::<SlashCommand> {
|
||||
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<anyhow::Result<()>> = None;
|
||||
|
||||
{ // Scope for state lock
|
||||
let state_guard = self.state.lock().await;
|
||||
let lavalink = &state_guard.lavalink;
|
||||
|
||||
for cmd_def in inventory::iter::<SlashCommand> {
|
||||
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<VoiceState>, 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
|
||||
85
src/lavalink_handler.rs
Normal file
85
src/lavalink_handler.rs
Normal file
@ -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<Http>
|
||||
|
||||
|
||||
// --- 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<Http>, // 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
123
src/main.rs
Normal file
123
src/main.rs
Normal file
@ -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::<u16>()
|
||||
.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<Http> 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.");
|
||||
}
|
||||
41
src/mod.rs
Normal file
41
src/mod.rs
Normal file
@ -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<CreateApplicationCommandOption>,
|
||||
// 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;
|
||||
17
src/state.rs
Normal file
17
src/state.rs
Normal file
@ -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<Http>,
|
||||
// Optional: Store announcement channels per guild for track announcements.
|
||||
// pub announcement_channels: Mutex<HashMap<GuildId, ChannelId>>,
|
||||
}
|
||||
25
src/utils.rs
Normal file
25
src/utils.rs
Normal file
@ -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<VoiceState> {
|
||||
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())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user