diff --git a/Cargo.lock b/Cargo.lock index cd4694e..55d56bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1338,6 +1338,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "queues" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1475abae4f8ad4998590fe3acfe20104f0a5d48fc420c817cd2c09c3f56151f0" + [[package]] name = "quote" version = "1.0.35" @@ -1561,6 +1567,8 @@ dependencies = [ "chrono", "confy", "futures", + "once_cell", + "queues", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8cd9d80..02c692f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,5 @@ tracing = "0.1.40" tracing-subscriber = "0.3.18" tracing-futures = "0.2.5" futures = "0.3.1" +queues = "1" +once_cell = "1" diff --git a/src/commands/info.rs b/src/commands/info.rs index 4dc5807..2c93d36 100644 --- a/src/commands/info.rs +++ b/src/commands/info.rs @@ -7,9 +7,9 @@ pub async fn run(_ctx: &Context, command: &CommandInteraction) -> CreateEmbed { let username = command.user.name.as_str(); Embed::create( - username, "", - "Botendo v7\ndeveloped by [moonleay](https://moonleay.net)\n\nCheck out the repository: https://git.moonleay.net/DiscordBots/Rustendo", + "Botendo v7", + "developed by [moonleay](https://moonleay.net)\n\nCheck out the repository: https://git.moonleay.net/DiscordBots/Rustendo", ) } diff --git a/src/commands/play.rs b/src/commands/play.rs index 3102f5a..1df5511 100644 --- a/src/commands/play.rs +++ b/src/commands/play.rs @@ -1,15 +1,15 @@ use serenity::all::{CommandDataOptionValue, CommandInteraction, Context}; use serenity::builder::{CreateCommand, CreateCommandOption, CreateEmbed}; use serenity::model::application::CommandOptionType; +use crate::music::music_manager; use crate::util::embed::Embed; -use crate::util::user_util::{self, get_vc_id}; pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed { let username = command.user.name.as_str(); let options = &command.data.options; - - let query = command.data.options.first().and_then(|option| { + + let query = options.first().and_then(|option| { if let CommandDataOptionValue::String(query) = &option.value { Some(query) } else { @@ -28,40 +28,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed { } }; - println!("Guild ID: {:?}", guild_id); - - let connect_to = get_vc_id(ctx, &guild_id, &command.user.id).await.expect("Cannot get channel id"); - - let manager = &songbird::get(ctx) - .await - .expect("Cannot get Songbird.") - .clone(); - - let self_channel = user_util::get_self_vc_id(ctx, &guild_id).await; - if !user_util::is_self_connected_to_vc(ctx, &guild_id).await { - // self is connected to vc, check if user is in same vc - - if self_channel.is_none() { - // Connect to VC - manager - .join(*guild_id, connect_to) - .await - .expect("Cannot connect>..."); - } - - } - let self_channel = self_channel.expect("Cannot get self channel"); - - // Check if user is in the same VC as the bot - if self_channel != connect_to { - return Embed::create( - username, - "You are not in my VC.", - "Connect to my VC to control the music.", - ); - } - - Embed::create(username, "Searching...", format!("Looking for {:?}", query)) + music_manager::attempt_to_queue_song(&ctx, &guild_id, &command.user.id, &command.user.name, query.unwrap()).await } pub fn register() -> CreateCommand { diff --git a/src/commands/stop.rs b/src/commands/stop.rs index 002d6f9..2f0600a 100644 --- a/src/commands/stop.rs +++ b/src/commands/stop.rs @@ -1,8 +1,7 @@ use serenity::all::{CommandInteraction, Context}; use serenity::builder::{CreateCommand, CreateEmbed}; - +use crate::music::music_manager; use crate::util::embed::Embed; -use crate::util::user_util; pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed { let username = command.user.name.as_str(); @@ -14,38 +13,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) -> CreateEmbed { } }; - if !user_util::is_self_connected_to_vc_cached(ctx, guild_id) { - // Bot is not connectd to vc; no need to dc - return Embed::create( - username, - "Bot is not connected", - "And therefore I cannot stop playing.", - ); - } - - let manager = songbird::get(ctx) - .await - .expect("Cannot get Songbird") - .clone(); - - let has_handler = manager.get(*guild_id).is_some(); - - if has_handler { - if let Err(e) = manager.remove(*guild_id).await { - return Embed::create(username, "There was an error", format!("Failed: {:?}", e)); - } - return Embed::create( - username, - "I stopped and left\nJust like your girlfriend.", - "", - ); - } - - Embed::create( - username, - "Bot is not connected", - "And therefore I cannot stop playing.\nSomething happend, which shouldn't have.", - ) + music_manager::attempt_to_stop(&ctx, &guild_id, &command.user.id, &command.user.name).await } pub fn register() -> CreateCommand { diff --git a/src/handler/mod.rs b/src/handler/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.rs b/src/main.rs index ed4f368..7e36070 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,15 @@ mod commands; -mod handler; mod util; +mod music; -use serenity::all::{CommandInteraction, OnlineStatus}; +use serenity::all::{CommandInteraction, CreateInteractionResponseFollowup, OnlineStatus, VoiceState}; use serenity::async_trait; -use serenity::builder::{CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage}; +use serenity::builder::{CreateEmbed}; use serenity::gateway::ActivityData; use serenity::model::application::{Command, Interaction}; use serenity::model::gateway::Ready; use serenity::prelude::*; -use util::{config, embed::Embed}; +use util::{config, embed::Embed, user_util}; // This trait adds the `register_songbird` and `register_songbird_with` methods // to the client builder below, making it easy to install this voice client. @@ -30,6 +30,8 @@ struct Handler; impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { if let Interaction::Command(command) = interaction { + let _ = &command.defer(&ctx.http()).await.expect("Cannot defer"); + let content = Some(match command.data.name.as_str() { "info" => commands::info::run(&ctx, &command).await, "play" => commands::play::run(&ctx, &command).await, @@ -38,10 +40,9 @@ impl EventHandler for Handler { }); if let Some(embed) = content { - let data = CreateInteractionResponseMessage::new().embed(embed); - let builder = CreateInteractionResponse::Message(data); - if let Err(why) = command.create_response(&ctx.http, builder).await { - println!("Cannot respond to slash command: {why}"); + let followup = CreateInteractionResponseFollowup::new().embed(embed); + if let Err(why) = command.create_followup(&ctx.http, followup).await { + println!("Cannot followup to slash command: {why}") } } } @@ -53,8 +54,24 @@ impl EventHandler for Handler { let _command = Command::create_global_command(&ctx.http, commands::info::register()).await; let _command = Command::create_global_command(&ctx.http, commands::stop::register()).await; let _command = Command::create_global_command(&ctx.http, commands::play::register()).await; + println!("Commands are registered and Rustendo is ready for Freddy."); + } - println!("Created all public / commands"); + async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { + if !new.channel_id.is_none() { + return; // User did not leave, ignore + } + if let Some(old) = old { + if !user_util::is_self_connected_to_vc(&ctx, &old.guild_id.unwrap()).await { + return; // Bot is not connected to a VC, ignore + } + if user_util::get_amount_of_members_in_vc(&ctx, &old.guild_id.unwrap(), &old.channel_id.unwrap()).await < 2 { + let manager = songbird::get(&ctx).await.expect("Cannot get Songbird"); + if let Err(e) = manager.remove(old.guild_id.unwrap()).await { + println!("Failed to remove handler: {:?}", e); + } + } + } // else: new user joined, ignore } } diff --git a/src/music/mod.rs b/src/music/mod.rs new file mode 100644 index 0000000..f16ee08 --- /dev/null +++ b/src/music/mod.rs @@ -0,0 +1,2 @@ +pub mod music_manager; +pub mod music_events; \ No newline at end of file diff --git a/src/music/music_events.rs b/src/music/music_events.rs new file mode 100644 index 0000000..ca48045 --- /dev/null +++ b/src/music/music_events.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; +use queues::IsQueue; +use serenity::all::{ChannelId, GuildId, Http}; +use serenity::async_trait; +use songbird::{Event, EventContext, EventHandler}; +use crate::music::music_manager; + +pub struct TrackEndNotifier { + pub guild_id: GuildId, + pub channel_id: ChannelId, + pub http: Arc, + pub cmdctx: Arc, +} + +#[async_trait] +impl EventHandler for TrackEndNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + unsafe { // TODO: Does this need to be unsafe? + if let EventContext::Track(track_list) = ctx { + println!("The track ended!"); + let queue = music_manager::get_queue(&self.guild_id); + if queue.size() == 0 { + let stopped = match music_manager::stop(&self.cmdctx, &self.guild_id).await { + Ok(stopped) => stopped, + Err(e) => { + println!("Cannot stop: {:?}", e); + return None; + } + }; + if stopped { + println!("Stopped playing successfully."); + } else { + println!("Failed to stop playing."); + } + return None; + } + } + } + + None + } +} diff --git a/src/music/music_manager.rs b/src/music/music_manager.rs new file mode 100644 index 0000000..7781b98 --- /dev/null +++ b/src/music/music_manager.rs @@ -0,0 +1,173 @@ +use std::collections::{HashMap}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use once_cell::sync::Lazy; +use queues::Queue; +use serenity::all::{Context, CreateEmbed, GuildId, UserId}; +use songbird::{Event, TrackEvent}; +use songbird::error::JoinError; +use songbird::input::{Compose, YoutubeDl}; +use crate::HttpKey; +use crate::music::music_events; +use crate::util::embed::Embed; +use crate::util::user_util; +use crate::util::user_util::get_vc_id; + +/// pub static mut MUSIC_QUEUE: HashMap> = HashMap::new(); +pub static mut MUSIC_QUEUE: Lazy>>> = Lazy::new(|| { + Mutex::new(HashMap::new()) +}); // TODO: This does not work and this is not the way to do it. This is a placeholder for now. + +pub unsafe fn get_queue(guild_id: &GuildId) -> &Queue { + MUSIC_QUEUE.lock().unwrap().entry(*guild_id).or_insert_with(Queue::new) +} + +pub async fn attempt_to_queue_song(ctx: &Context, guild_id: &GuildId, user_id: &UserId, username: &str, query: &str) -> CreateEmbed { + if !user_util::is_user_connected_to_vc(ctx, guild_id, user_id).await { + return Embed::create(username, "You are not connected to a VC", "Connect to my VC to control the music."); + } + + let connect_to = match get_vc_id(ctx, &guild_id, &user_id).await { + Some(channel_id) => channel_id, + None => { + return Embed::create(username, "Error", "Cannot get channel id"); + } + + }; + + let manager = &songbird::get(ctx) + .await + .expect("Cannot get Songbird.") + .clone(); + + let self_channel = user_util::get_self_vc_id(ctx, &guild_id).await; + if !user_util::is_self_connected_to_vc(ctx, &guild_id).await { + // self is connected to vc, check if user is in same vc + + if self_channel.is_none() { // TODO This could maybe be removed? + // Connect to VC + manager + .join(*guild_id, connect_to) + .await + .expect("Cannot connect>..."); + } + + } else { + let self_channel = self_channel.expect("Cannot get self channel"); + + // Check if user is in the same VC as the bot + if self_channel != connect_to { + return Embed::create( + username, + "You are not in my VC.", + "Connect to my VC to control the music.", + ); + } + } + + // Get query + let do_search = !query.starts_with("http"); + let http_client = { + let data = ctx.data.read().await; + data.get::() + .cloned() + .expect("Guaranteed to exist in the typemap.") + }; + + // Create source + let mut src = if do_search { + YoutubeDl::new_search(http_client, query.to_string()) + } else { + YoutubeDl::new(http_client, query.to_string()) + }; + let handler_lock = match manager.get(*guild_id) { + Some(handler) => handler, + None => { + return Embed::create(username, "Error", "Cannot get handler"); + }, + }; + + // Start playing + let mut handler = handler_lock.lock().await; + let track_handle = handler.play_input(src.clone().into()); // TODO: Add event handlers + handler.add_global_event( + Event::Track(TrackEvent::End), + music_events::TrackEndNotifier { + guild_id: *guild_id, + channel_id: connect_to, + http: Arc::clone(&ctx.http), + cmdctx: Arc::new(ctx.clone()), + }, + ); + + // Get metadata + let metadata = src.aux_metadata().await.expect("Cannot get metadata"); + let title = metadata.title.unwrap_or("Unknown title".to_string()); + let author = metadata.artist.unwrap_or("Unknown artist".to_string()); + let duration = metadata.duration.unwrap_or(Duration::from_millis(0)); + let thumbnail = metadata.thumbnail.unwrap_or("https://http.cat/images/404.jpg".to_string()); + let link = metadata.source_url.unwrap_or("https://piped.moonleay.net/404".to_string()); + + Embed::create(username, "Added to queue", format!("{} by {} ({}min {}sec) was added to the queue.\n [[Link]({})]", + title, author, duration.as_secs() / 60, duration.as_secs() % 60, link)) + .thumbnail(thumbnail) +} + + +pub async fn attempt_to_stop(ctx: &Context, guild_id: &GuildId, user_id: &UserId, username: &str) -> CreateEmbed { + if !user_util::is_self_connected_to_vc(ctx, guild_id).await { + // Bot is not connectd to vc; no need to dc + return Embed::create( + username, + "Bot is not connected", + "And therefore there is no need to do anything.", + ); + } + let self_channel = user_util::get_self_vc_id(ctx, &guild_id).await.expect("Cannot get self channel"); + let connect_to = get_vc_id(ctx, &guild_id, &user_id).await.expect("Cannot get channel id"); + + // Check if user is in the same VC as the bot + if self_channel != connect_to { + return Embed::create( + username, + "You are not in my VC.", + "Connect to my VC to control the music.", + ); + } + + let stopped = match stop(ctx, guild_id).await { + Ok(stopped) => stopped, + Err(e) => { + println!("Error while stopping: {:?}", e); + return Embed::create(username, "There was an error", "Tell moonleay to check the logs.".to_string()); + } + }; + + if !stopped { + return Embed::create(username, "Can't stop, what ain't running.", "I am not connected. I cant stop doing something, when I'm not doing it".to_string()); + } else { + return Embed::create( + username, + "I stopped and left", + "Just like your girlfriend.", + ); + } +} + +// Make the bot leave the voice channel. Returns Ok(true) if bot was connected, returns Ok(false) if bot was not connected. Returns Err if something went wrong. +pub async fn stop(ctx: &Context, guild_id: &GuildId) -> Result { + let manager = songbird::get(ctx) + .await + .expect("Cannot get Songbird") + .clone(); + + let has_handler = manager.get(*guild_id).is_some(); + + if has_handler { + if let Err(e) = manager.remove(*guild_id).await { + return Err(e); // Failed to remove handler + } + return Ok(true) // Handler removed + } + Ok(false) // No handler, so it's already stopped +} diff --git a/src/util/embed.rs b/src/util/embed.rs index 47a7d0c..bfdb2f1 100644 --- a/src/util/embed.rs +++ b/src/util/embed.rs @@ -22,7 +22,7 @@ impl Embed { .title(title) .description(message) .footer(CreateEmbedFooter::new(format!( - "> {} | {}", + "> {} - {}", current_time, username ))) } diff --git a/src/util/user_util.rs b/src/util/user_util.rs index ef0c742..8e7bf74 100644 --- a/src/util/user_util.rs +++ b/src/util/user_util.rs @@ -1,21 +1,7 @@ -use serenity::all::{CacheHttp, ChannelId, Context, Guild, GuildId, GuildRef, PartialGuild, UserId}; -use serenity::Error; - -/// Get a guild by id -pub fn get_guild_cached(ctx: &Context, guild_id: &GuildId) -> Option { - let guild = match ctx.cache.guild(guild_id) { - Some(guild) => guild, - None => { - println!("Cannot get guild with id {:?}!", guild_id); - return None - } - }; - - println!("Got guild: {:?}", guild.name); - - Some(guild.clone()) -} +use std::collections::HashMap; +use serenity::all::{ChannelId, Context, Guild, GuildId, PartialGuild, UserId, VoiceState}; +/// Request a guild by id, get it from cache pub fn request_guild(ctx: &Context, guild_id: &GuildId) -> Guild { let guild = match guild_id.to_guild_cached(&ctx.cache) { Some(guild) => guild.clone(), @@ -39,26 +25,7 @@ pub async fn request_partial_guild(ctx: &Context, guild_id: &GuildId) -> Partial guild } -/// Get the current channel id of the bot -pub fn get_vc_id_cached(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> Option { - let guild = match get_guild_cached(&ctx, guild_id){ - Some(guild) => guild, - None => { - println!("Cannot get guild while getting channel id!"); - return None - } - }; - - let channel_id = guild - .voice_states - .get(user_id) - .and_then(|voice_state| voice_state.channel_id)?; - - println!("Got vc with id: {:?}", channel_id); - - Some(channel_id) -} - +/// Get the voice channel id of a user pub async fn get_vc_id(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> Option { let guild = request_guild(&ctx, guild_id); guild @@ -69,27 +36,40 @@ pub async fn get_vc_id(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> O } /// Check if the bot is connected to a voice channel -pub fn is_self_connected_to_vc_cached(ctx: &Context, guild_id: &GuildId) -> bool { - let channel_id = get_self_vc_id_cached(ctx, guild_id); - - !channel_id.is_none() -} - pub async fn is_self_connected_to_vc(ctx: &Context, guild_id: &GuildId) -> bool { let channel_id = get_self_vc_id(ctx, guild_id); !channel_id.await.is_none() } -/// Get the current channel id of the bot -pub fn get_self_vc_id_cached(ctx: &Context, guild_id: &GuildId) -> Option { - let channel_id = get_vc_id_cached(ctx, guild_id, &ctx.cache.current_user().id)?; +/// Check if a user is connected to a voice channel +pub async fn is_user_connected_to_vc(ctx: &Context, guild_id: &GuildId, user_id: &UserId) -> bool { + let channel_id = get_vc_id(ctx, guild_id, user_id).await; - Some(channel_id) + !channel_id.is_none() } +/// Get the voice channel id of the bot pub async fn get_self_vc_id(ctx: &Context, guild_id: &GuildId) -> Option { let user_id = ctx.cache.current_user().id; get_vc_id(ctx, guild_id, &user_id).await } + +/// Get all voice states of a guild +pub async fn get_voice_states(ctx: &Context, guild_id: &GuildId) -> HashMap{ + let guild = request_guild(ctx, guild_id); + let voice_states = guild.voice_states.clone(); + voice_states +} + + +/// Get the amount of members in a voice channel +pub async fn get_amount_of_members_in_vc(ctx: &Context, guild_id: &GuildId, channel_id: &ChannelId,) -> usize { + let voice_states = get_voice_states(ctx, guild_id).await; + let amount = voice_states + .iter() + .filter(|(_, voice_state)| voice_state.channel_id == Some(*channel_id)) + .count(); + amount +}