diff --git a/Cargo.toml b/Cargo.toml index 86760d8..52dbf78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,23 +1,20 @@ [package] -name = "dj-kitty-cat" -version = "0.1.0" -authors = ["my name "] -edition = "2018" +name = "dj_kitty_cat" +version = "0.2.0" +edition = "2021" [dependencies] -tracing = "0.1" -tracing-subscriber = "0.2" -tracing-futures = "0.2" +anyhow = "1.0.69" +parking_lot = "0.12.1" +poise = "0.5.2" +tracing = "0.1.37" +tracing-subscriber = "0.3.16" +tracing-futures = "0.2.5" [dependencies.songbird] -version = "0.2" -features = ["builtin-queue"] - -[dependencies.serenity] -version = "0.10" -default-features = false -features = ["cache", "client", "gateway", "model", "voice", "rustls_backend", "unstable_discord_api"] +version = "0.3.0" +features = ["yt-dlp"] [dependencies.tokio] -version = "1.0" +version = "1.26.0" features = ["macros", "rt-multi-thread", "signal"] diff --git a/src/commands.rs b/src/commands.rs index f960ac9..f9356fa 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,326 +1,110 @@ -use serenity::client::Context; - -use serenity::model::interactions::application_command::{ - ApplicationCommandInteraction, ApplicationCommandInteractionDataOptionValue, -}; -use serenity::utils::{EmbedMessageBuilding, MessageBuilder}; +use anyhow::{Context, Result}; +use poise::serenity_prelude::{EmbedMessageBuilding, MessageBuilder}; use songbird::create_player; -use songbird::input::Input; +use tracing::debug; -use crate::{CurrentVolume, CurrentlyPlayingTrack}; +use crate::{CommandContext, Error}; -pub async fn join(ctx: &Context, command: &ApplicationCommandInteraction) -> String { - let guild_id = command.guild_id.unwrap(); - let guild = guild_id.to_guild_cached(&ctx.cache).await.unwrap(); - - let channel_id = guild - .voice_states - .get(&command.user.id) - .and_then(|voice_state| voice_state.channel_id); - - let connect_to = match channel_id { - Some(channel) => channel, - None => { - return "You're not in a voice channel. How do I know where to go?".to_string(); - } +#[poise::command(slash_command)] +pub async fn join(ctx: CommandContext<'_>) -> Result<(), Error> { + let Some(guild) = ctx.guild() else { + ctx.say("You're not in a server, silly.").await?; + return Ok(()); }; - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let _handler = manager.join(guild_id, connect_to).await; - - "Joining your channel!".to_string() -} - -pub async fn leave(ctx: &Context, command: &ApplicationCommandInteraction) -> String { - let guild_id = command.guild_id.unwrap(); - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - let has_handler = manager.get(guild_id).is_some(); - - if has_handler { - if let Err(e) = manager.remove(guild_id).await { - return format!("Failed: {:?}", e); - } - - "Goodbye!".to_string() - } else { - "I can't leave if I'm not there to bein with!".to_string() - } -} - -pub async fn play(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { - let options = command - .data - .options - .get(0) - .expect("Expected url option") - .resolved - .as_ref() - .expect("Expected url object"); - - let url = match options { - ApplicationCommandInteractionDataOptionValue::String(url) => url, - _ => { - return "You didn't tell me what to play.".to_string(); - } + let Some(Some(channel_id)) = guild.voice_states.get(&ctx.author().id).map(|vs| vs.channel_id) else { + ctx.say("You're not in a voice channel, silly.").await?; + return Ok(()); }; - if !url.starts_with("http") { - return "That's not a real URL. I'm onto you.".to_string(); - } - - let guild_id = command.guild_id.unwrap(); - - let manager = songbird::get(ctx) + let manager = songbird::get(ctx.serenity_context()) .await - .expect("Songbird Voice client placed in at initialisation.") + .context("Expected a songbird manager")? + .clone(); + let _handler = manager.join(guild.id, channel_id).await; + + ctx.say("Joining your channel!").await?; + + Ok(()) +} + +#[poise::command(slash_command)] +pub async fn leave(ctx: CommandContext<'_>) -> Result<(), Error> { + let Some(guild) = ctx.guild() else { + ctx.say("You're not in a server, silly.").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .context("Expected a songbird manager")? .clone(); - // Try to join the caller's channel - if manager.get(guild_id).is_none() { - join(ctx, command).await; + if manager.get(guild.id).is_none() { + ctx.say("I'm not even in a voice channel!").await?; + return Ok(()); } - if let Some(handler_lock) = manager.get(guild_id) { + let _handler = manager.remove(guild.id).await; + + ctx.say("Okay bye!").await?; + + Ok(()) +} + +#[poise::command(slash_command)] +pub async fn play( + ctx: CommandContext<'_>, + #[description = "The URL of the song to play"] url: Option, +) -> Result<(), Error> { + let Some(url) = url else { + ctx.say("You need to give me a URL to play!").await?; + return Ok(()); + }; + + let Some(guild) = ctx.guild() else { + ctx.say("You're not in a server, silly.").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .context("Expected a songbird manager")? + .clone(); + + if manager.get(guild.id).is_none() { + let Some(Some(channel_id)) = guild.voice_states.get(&ctx.author().id).map(|vs| vs.channel_id) else { + ctx.say("Neither of us are in a voice channel, silly.").await?; + return Ok(()); + }; + let _handler = manager.join(guild.id, channel_id).await; + } + + if let Some(handler_lock) = manager.get(guild.id) { let mut handler = handler_lock.lock().await; - let source = match songbird::input::Restartable::ytdl(url.clone(), false).await { - Ok(source) => source, - Err(why) => { - println!("Err starting source: {:?}", why); + debug!("Trying to play: {}", url); + let source = songbird::ytdl(&url).await?; + debug!("Playing: {:?}", source.metadata); + let title = source + .metadata + .title + .clone() + .unwrap_or(String::from("This video")); + let msg = MessageBuilder::new() + .push("Now playing: ") + .push_named_link(title, url) + .build(); + ctx.say(msg).await?; - return "Something went horribly wrong. Go yell at Valter.".to_string(); - } - }; - - let source_input: Input = source.into(); - - let message = { - if let Some(title) = &source_input.metadata.title { - let mut msg = MessageBuilder::new(); - msg.push_line("Playing this:"); - msg.push_named_link(title, url); - msg.build() - } else { - "Playing something, I dunno what.".to_string() - } - }; - - let (mut audio, track_handle) = create_player(source_input); - - let mut data = ctx.data.write().await; - - let current_track = data.get_mut::().unwrap(); - *current_track = Some(track_handle); - - let volume = data.get::().unwrap(); - - audio.set_volume(*volume); + let (audio, track_handle) = create_player(source); + let mut currently_playing = ctx.data().currently_playing.lock(); + *currently_playing = Some(track_handle); handler.play_only(audio); - - message } else { - "Somehow neither of us are in a voice channel to begin with.".to_string() - } -} - -pub async fn queue(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { - let options = command - .data - .options - .get(0) - .expect("Expected url option") - .resolved - .as_ref() - .expect("Expected url object"); - - let url = match options { - ApplicationCommandInteractionDataOptionValue::String(url) => url, - _ => { - return "You didn't tell me what to play.".to_string(); - } - }; - - if !url.starts_with("http") { - return "That's not a real URL. I'm onto you.".to_string(); - } - - let guild_id = command.guild_id.unwrap(); - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - // Try to join the caller's channel - if manager.get(guild_id).is_none() { - join(ctx, command).await; - } - - if let Some(handler_lock) = manager.get(guild_id) { - let mut handler = handler_lock.lock().await; - - let source = match songbird::input::Restartable::ytdl(url.clone(), false).await { - Ok(source) => source, - Err(why) => { - println!("Err starting source: {:?}", why); - - return "Something went horribly wrong. Go yell at Valter.".to_string(); - } - }; - - let source_input: Input = source.into(); - - let message = { - if let Some(title) = &source_input.metadata.title { - let mut msg = MessageBuilder::new(); - msg.push_line(format!( - "Queueing this up at position {}:", - handler.queue().len() - )); - msg.push_named_link(title, url); - msg.build() - } else { - format!( - "Queueing something up at position {}, I dunno what.", - handler.queue().len() - ) - .to_string() - } - }; - - let (mut audio, track_handle) = create_player(source_input); - - let mut data = ctx.data.write().await; - - let current_track = data.get_mut::().unwrap(); - *current_track = Some(track_handle); - - let volume = data.get::().unwrap(); - - audio.set_volume(*volume); - handler.enqueue(audio); - - message - } else { - "Somehow neither of us are in a voice channel to begin with.".to_string() - } -} - -pub async fn skip(ctx: &Context, command: &ApplicationCommandInteraction) -> String { - let guild_id = command.guild_id.unwrap(); - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - if let Some(handler_lock) = manager.get(guild_id) { - let handler = handler_lock.lock().await; - - let queue = handler.queue(); - let _ = queue.skip(); - - format!("Yeah, I didn't like this one very much anyway. Skip! Now we're at number {} in the queue.", queue.len()) - } else { - "I'm not even in a channel to begin with. Silly.".to_string() - } -} - -pub async fn stop(ctx: &Context, command: &ApplicationCommandInteraction) -> String { - let guild_id = command.guild_id.unwrap(); - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - if let Some(handler_lock) = manager.get(guild_id) { - let mut handler = handler_lock.lock().await; - - handler.stop(); - - "Alright, I guess I'll stop.".to_string() - } else { - "I'm not even in a channel to begin with. Silly.".to_string() - } -} - -pub async fn set_volume(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { - let options = command - .data - .options - .get(0) - .expect("Expected volume option") - .resolved - .as_ref() - .expect("Expected volume object"); - - let volume = match options { - ApplicationCommandInteractionDataOptionValue::Number(volume) => *volume, - _ => { - return "You've gotta give me a volume level to set.".to_string(); - } - }; - - if !(0.0..=100.0).contains(&volume) { - return "Volume has to be between 0 and 100.".to_string(); - } - - let mut data = ctx.data.write().await; - - let current_volume = data.get_mut::().unwrap(); - let new_volume = (volume / 100.0) as f32; - *current_volume = new_volume; - - let current_track = data.get::().unwrap(); - if let Some(track) = current_track { - if track.set_volume(new_volume).is_err() { - return format!( - "Setting volume to {}%, but it didn't work for the current track for some reason.", - volume - ); - } - } - - format!("Setting volume to {}%.", volume) -} - -pub async fn set_loop(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String { - let options = command - .data - .options - .get(0) - .expect("Expected loop option") - .resolved - .as_ref() - .expect("Expected loop object"); - - let loops = match options { - ApplicationCommandInteractionDataOptionValue::Boolean(loops) => *loops, - _ => { - return "Do you want me to loop the song or not? Be specific.".to_string(); - } - }; - - let data = ctx.data.write().await; - let current_track = data.get::().unwrap(); - if let Some(track) = current_track { - if loops { - track.enable_loop().expect("Couldn't enable looping"); - "Loopin'!".to_string() - } else { - track.disable_loop().expect("Couldn't disable looping"); - "This is the last time this track will EVER be played.".to_string() - } - } else { - "I can't loop a song if there is no song to loop.".to_string() + ctx.say("Neither of us are in a voice channel, silly.") + .await?; } + + Ok(()) } diff --git a/src/main.rs b/src/main.rs index 6b87ae1..47b4014 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,222 +1,81 @@ #![warn(clippy::all)] -use std::env; - -use serenity::{ - async_trait, - model::{ - gateway::Ready, - id::GuildId, - interactions::{ - application_command::{ApplicationCommandOptionType, ApplicationCommandPermissionType}, - Interaction, InteractionResponseType, - }, - }, - prelude::*, -}; -use songbird::{tracks::TrackHandle, SerenityInit}; - mod commands; -struct CurrentlyPlayingTrack; +use commands::*; -impl TypeMapKey for CurrentlyPlayingTrack { - type Value = Option; +use std::{env, sync::Arc}; + +use anyhow::Result; +use parking_lot::Mutex; +use poise::serenity_prelude::{self as serenity}; +use songbird::serenity::SerenityInit; +use tracing_subscriber::filter; + +pub struct Data { + currently_playing: Arc>>, +} +pub type Error = Box; +pub type CommandContext<'a> = poise::Context<'a, Data, Error>; + +async fn event_event_handler( + _ctx: &serenity::Context, + event: &poise::Event<'_>, + _framework: poise::FrameworkContext<'_, Data, Error>, + _user_data: &Data, +) -> Result<(), Error> { + if let poise::Event::Ready { data_about_bot } = event { + println!("{} is connected!", data_about_bot.user.name) + } + + Ok(()) } -struct CurrentVolume; +/// Registers slash commands in this guild or globally +#[poise::command(prefix_command, hide_in_help)] +async fn register(ctx: CommandContext<'_>) -> Result<(), Error> { + poise::builtins::register_application_commands_buttons(ctx).await?; -impl TypeMapKey for CurrentVolume { - type Value = f32; -} - -struct Handler; - -#[async_trait] -impl EventHandler for Handler { - async fn interaction_create(&self, mut ctx: Context, interaction: Interaction) { - if let Interaction::ApplicationCommand(command) = interaction { - let content = match command.data.name.as_str() { - "join" => commands::join(&ctx, &command).await, - "leave" => commands::leave(&ctx, &command).await, - "play" => commands::play(&mut ctx, &command).await, - "queue" => commands::queue(&mut ctx, &command).await, - "skip" => commands::skip(&mut ctx, &command).await, - "stop" => commands::stop(&ctx, &command).await, - "volume" => commands::set_volume(&mut ctx, &command).await, - "loop" => commands::set_loop(&mut ctx, &command).await, - _ => "not implemented :(".to_string(), - }; - - if let Err(why) = command - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| message.content(content)) - }) - .await - { - println!("Cannot respond to slash command: {}", why); - } - } - } - - async fn cache_ready(&self, ctx: Context, guilds: Vec) { - for guild in guilds { - let commands = guild - .set_application_commands(&ctx.http, |commands| { - commands - .create_application_command(|command| { - command - .name("join") - .description("Join your current channel") - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("leave") - .description("Leave the bot's current channel") - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("play") - .description("Play a song") - .create_option(|option| { - option - .name("url") - .description("The URL of the song to play") - .kind(ApplicationCommandOptionType::String) - .required(true) - }) - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("queue") - .description("Queue up a song") - .create_option(|option| { - option - .name("url") - .description("The URL of the song to queue") - .kind(ApplicationCommandOptionType::String) - .required(true) - }) - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("skip") - .description("Skip the currently playing queued song") - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("stop") - .description("Stop any currently playing songs") - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("volume") - .description("Set the bot's playback volume") - .create_option(|option| { - option - .name("volume") - .description("The volume on a scale from 0 to 100") - .kind(ApplicationCommandOptionType::Number) - .required(true) - }) - .default_permission(false) - }) - .create_application_command(|command| { - command - .name("loop") - .description( - "Enable or disable looping the currently playing track", - ) - .create_option(|option| { - option - .name("loop") - .description("Whether or not the track should loop") - .kind(ApplicationCommandOptionType::Boolean) - .required(true) - }) - .default_permission(false) - }) - }) - .await - .expect("Couldn't create commands"); - - println!("I created the following guild commands: {:#?}", commands); - - let role_id = env::var("ROLE_ID") - .expect("Expected a role id in the environment") - .parse::() - .expect("Role id is not a valid id"); - - let permissions = guild - .set_application_commands_permissions(&ctx.http, |permissions| { - for command in commands { - permissions.create_application_command(|permissions| { - permissions - .id(command.id.into()) - .create_permissions(|permission| { - permission - .id(role_id) - .kind(ApplicationCommandPermissionType::Role) - .permission(true) - }) - }); - } - permissions - }) - .await - .expect("Couldn't set permissions"); - - println!("I created the following permissions: {:#?}", permissions); - } - } - - async fn ready(&self, _ctx: Context, ready: Ready) { - println!("{} is connected!", ready.user.name); - } + Ok(()) } #[tokio::main] -async fn main() { +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_max_level(filter::LevelFilter::DEBUG) + .init(); + let token = env::var("DISCORD_TOKEN").expect("Expected a bot token in the environment: DISCORD_TOKEN"); - let application_id: u64 = env::var("APPLICATION_ID") - .expect("Expected an application id in the environment: APPLICATION_ID") - .parse() - .expect("application id is not a valid id"); + let options = poise::FrameworkOptions { + commands: vec![register(), join(), leave(), play()], + event_handler: |ctx, event, framework, user_data| { + Box::pin(event_event_handler(ctx, event, framework, user_data)) + }, + prefix_options: poise::PrefixFrameworkOptions { + prefix: Some(String::from("~")), + ..Default::default() + }, + ..Default::default() + }; - env::var("ROLE_ID") - .expect("Expected a role id in the environment: ROLE_ID") - .parse::() - .expect("Role id is not a valid id"); + let intents = serenity::GatewayIntents::non_privileged(); - env::var("CHANNEL_ID") - .expect("Expected a channel id in the environment: CHANNEL_ID") - .parse::() - .expect("Channel id is not a valid id"); + poise::Framework::builder() + .token(token) + .options(options) + .intents(intents) + .setup(|_ctx, _data, _framework| { + Box::pin(async move { + Ok(Data { + currently_playing: Arc::new(Mutex::new(None)), + }) + }) + }) + .client_settings(|client_builder| client_builder.register_songbird()) + .run() + .await?; - let mut client = Client::builder(token) - .event_handler(Handler) - .application_id(application_id) - .register_songbird() - .await - .expect("Error creating client"); - - { - let mut data = client.data.write().await; - data.insert::(None); - data.insert::(1.0); - } - - if let Err(why) = client.start().await { - println!("Client error: {:?}", why); - } + Ok(()) }