diff --git a/.gitignore b/.gitignore index cfab8b8..cf2dd4c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ .idea/ **/Cargo.lock +.DS_Store .next/ node_modules/ diff --git a/README.md b/README.md index 10d4b3b..f5b4a75 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,14 @@ The CLIENT_ID can be found in the General Information tab on the Discord Develop The following packages must be installed for [serenity-rs/songbird](https://github.com/serenity-rs/songbird). View the repository for additional installation and setup information on other operating systems.
Unix Installation + Notes: + + - [yt-dlp](https://github.com/yt-dlp/yt-dlp/releases) is preferred over youtube-dl. ``` sudo apt install libopus-dev sudo apt install ffmpeg - sudo apt apt install youtube-dl + sudo apt apt install youtube-dl # See notes above # PostgreSQL Headers sudo apt install libpq5 sudo apt install libpq-dev @@ -45,11 +48,15 @@ The following packages must be installed for [serenity-rs/songbird](https://gith
Mac Installation + Notes: + + - [Homebrew](https://brew.sh/) must be installed to run the following commands. + - [youtube-dl](https://formulae.brew.sh/formula/youtube-dl#default) is deprecated, [yt-dlp](https://formulae.brew.sh/formula/yt-dlp) is preferred ``` brew install opus brew install ffmpeg - brew install youtube-dl + brew install yt-dlp # See notes above brew install postgresql ```
diff --git a/service/Cargo.toml b/service/Cargo.toml index 238273a..d8b0224 100644 --- a/service/Cargo.toml +++ b/service/Cargo.toml @@ -31,6 +31,7 @@ base64 = "0.21.4" rust-s3 = "0.33.0" actix-multipart = "0.6.1" openssl = "0.10.60" # Resolve `openssl` `X509StoreRef::objects` is unsound #10 +rand = "0.8.5" [dependencies.tokio] version = "1.32.0" diff --git a/service/src/bot/commands/audio/mod.rs b/service/src/bot/commands/audio/mod.rs index 40b3e24..c82f5b7 100644 --- a/service/src/bot/commands/audio/mod.rs +++ b/service/src/bot/commands/audio/mod.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use log::{debug, warn}; +use reqwest::Url; use serenity::client::Cache; use serenity::model::application::interaction::{InteractionResponseType, application_command::ApplicationCommandInteraction}; use serenity::model::prelude::{GuildId, ChannelId}; @@ -11,6 +12,8 @@ use siren::ServiceError; use songbird::{Call, Songbird}; use songbird::input::{Restartable, Input, Metadata, error::Error as SongbirdError}; +use crate::bot::ytdlp::{PlaylistItem, YtDlp}; + pub mod pause; pub mod play; pub mod resume; @@ -88,11 +91,7 @@ pub async fn edit_response(ctx: &Context, command: &ApplicationCommandInteractio } pub async fn add_song(call: Arc>, url: &str, lazy: bool, volume: Option) -> Result { - let source = if is_valid_url(url) { - Restartable::ytdl(url.to_owned(), lazy).await? - } else { - Restartable::ytdl_search(url, lazy).await? - }; + let source = Restartable::ytdl(url.to_owned(), lazy).await?; let mut handler = call.lock().await; let track: Input = source.into(); let metadata = *track.metadata.clone(); @@ -103,18 +102,41 @@ pub async fn add_song(call: Arc>, url: &str, lazy: bool, volume: Opt Ok(metadata) } -pub fn get_playlist_urls(url: &str) -> Result, String> { - let mut urls: Vec = Vec::new(); - // TODO fix this later - urls.push(url.to_string()); - Ok(urls) +pub fn get_playlist_urls(url: &str) -> Result, ServiceError> { + let output = YtDlp::new() + .arg("--flat-playlist") + .arg("--dump-json") + .arg(url) + .execute()?; + let items: Vec = String::from_utf8(output.stdout)? + .split('\n') + .filter_map(|line| { + if line.is_empty() { + None + } else { + Some( + serde_json::from_slice::(line.as_bytes()).map_err( + |err| ServiceError { status: 500, message: err.to_string() } + ) + ) + } + }) + .filter_map(|parsed| match parsed { + Ok(item) => Some(item), + Err(err) => { + warn!("Failed to parse playlist item: {}", err); + None + } + }) + .collect(); + Ok(items) } -fn is_valid_url(url: &str) -> bool { - match url.parse::() { - Ok(_) => return true, - Err(_) => return false - } +fn is_valid_url(url: &str) -> (bool, bool) { + Url::parse(url).ok().map_or((false, false), |valid_url| { + let is_playlist: bool = valid_url.query_pairs().find(|(key, _)| key == "list").map_or(false, |_| true); + (true, is_playlist) + }) } pub async fn get_songbird(ctx: &Context) -> Arc { diff --git a/service/src/bot/commands/audio/play.rs b/service/src/bot/commands/audio/play.rs index f7f950e..855d84d 100644 --- a/service/src/bot/commands/audio/play.rs +++ b/service/src/bot/commands/audio/play.rs @@ -9,9 +9,10 @@ use serenity::model::application::interaction::application_command::ApplicationC use siren::ServiceError; use songbird::{EventHandler, Songbird}; +use crate::bot::ytdlp::PlaylistItem; use crate::bot::{guilds::QueryGuild, commands::audio::{leave, get_playlist_urls, add_song, get_songbird}}; -use super::{create_response, edit_response, join_by_user}; +use super::{create_response, edit_response, is_valid_url, join_by_user}; pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { // Get the track url @@ -65,8 +66,14 @@ pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { debug!("Play command executed with track: {:?}", track_url); let manager = get_songbird(ctx).await; match play_track(manager, guild_id, track_url).await { - Ok(_) => { - if let Err(why) = edit_response(&ctx, &command, "Playing track".to_string()).await { + Ok(count) => { + let mut message = format!("Playing {} tracks", count); + if count == 0 { + message = "No tracks were played".to_string(); + } else if count == 1 { + message = "Playing 1 track".to_string(); + } + if let Err(why) = edit_response(&ctx, &command, message).await { error!("Failed to edit response message: {}", why); } }, @@ -95,15 +102,32 @@ pub async fn play_track(manager: Arc, guild_id: GuildId, track_url: St call_handler.queue().is_empty() }; let guild = QueryGuild::get(guild_id.0 as i64)?; - let track_urls = match get_playlist_urls(&track_url) { - Ok(urls) => urls, - Err(err) => { - warn!("Failed to get playlist urls: {}", err); - return Err(ServiceError { status: 422, message: err.to_string() }) - } - }; - for url in track_urls { - match add_song(handler_lock.clone(), &url, is_queue_empty, Some(guild.volume as f32 / 100.0)).await { + let valid = is_valid_url(&track_url); + if !valid.0 { + warn!("Invalid track url: {}", track_url); + return Err(ServiceError { status: 422, message: format!("Invalid track url: {}", track_url) }) + } + let mut playlist_items: Vec = Vec::new(); + if valid.1 { + playlist_items = match get_playlist_urls(&track_url) { + Ok(items) => items, + Err(err) => { + warn!("Failed to get playlist urls: {}", err); + return Err(ServiceError { status: 422, message: err.to_string() }) + } + }; + } else { + let playlist_item = PlaylistItem { + id: "".to_string(), + url: track_url, + title: "".to_string(), + duration: 0, + playlist_index: 0 + }; + playlist_items.push(playlist_item); + } + for item in playlist_items { + match add_song(handler_lock.clone(), &item.url, is_queue_empty, Some(guild.volume as f32 / 100.0)).await { Ok(added_song) => { let track_title = added_song.title.unwrap(); debug!("Added track: {}", track_title); diff --git a/service/src/bot/commands/chat.rs b/service/src/bot/commands/chat.rs index 00c9709..e41e628 100644 --- a/service/src/bot/commands/chat.rs +++ b/service/src/bot/commands/chat.rs @@ -1,4 +1,4 @@ -use log::{error, debug, warn}; +use log::{error, trace, warn}; use serenity::model::Permissions; use serenity::model::channel::Message; @@ -9,7 +9,7 @@ use crate::bot::messages::{QueryFilters, QueryMessage}; use crate::bot::oai::{ChatCompletionMessage, ChatCompletionRequest, GPTRole, OAI}; pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { - debug!("Generating response for message: {}", msg.content); + trace!("Generating response for message: {}", msg.content); let guild_id = msg.guild_id.unwrap(); let channel_id = msg.channel_id; @@ -90,7 +90,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { // Get the OAI response and store message/response into the database let response = match oai.chat_completion(request).await { Ok(r) => { - debug!("Processing response received from OpenAI"); + trace!("Processing response received from OpenAI"); if !r.choices.is_empty() { let res = r.choices[0].message.content.clone(); if let Err(err) = QueryMessage::insert(QueryMessage { @@ -118,7 +118,7 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { "There was an error processing your message. Please try again later.".to_string() } }; - debug!("Writing response: \"{}\"", response); + trace!("Writing response: \"{}\"", response); typing.stop(); if let Err(why) = response_channel.say(&ctx.http, response).await { @@ -142,7 +142,6 @@ pub async fn generate_response(ctx: &Context, msg: &Message, oai: &OAI) { } async fn generate_thread_name(oai: &OAI, s: &str, max_chars: usize) -> String { - println!("HERE: {}", s); let message = ChatCompletionMessage { role: GPTRole::User, content: format!("---\n{}\n---\nSummarize the message above into a concise Discord thread title", s) diff --git a/service/src/bot/commands/mod.rs b/service/src/bot/commands/mod.rs index 5479ddd..89262ed 100644 --- a/service/src/bot/commands/mod.rs +++ b/service/src/bot/commands/mod.rs @@ -3,3 +3,4 @@ pub mod help; pub mod chat; pub mod ping; pub mod schedule; +pub mod roll; diff --git a/service/src/bot/commands/roll.rs b/service/src/bot/commands/roll.rs new file mode 100644 index 0000000..456d232 --- /dev/null +++ b/service/src/bot/commands/roll.rs @@ -0,0 +1,134 @@ +use log::{error, warn}; +use rand::Rng; +use serenity::{builder::CreateApplicationCommand, client::Context, model::application::{command::CommandOptionType, interaction::application_command::ApplicationCommandInteraction}}; + +use crate::bot::commands::audio::edit_response; + +use super::audio::create_response; + +pub async fn run(ctx: &Context, command: &ApplicationCommandInteraction) { + if let Err(why) = create_response(&ctx, &command, format!("Processing command...")).await { + error!("Failed to create response message: {}", why); + return; + } + let dice_string: String = match command.data.options.get(0) { + Some(o) => match &o.value { + Some(v) => match v.as_str() { + Some(s) => s.split_whitespace().collect::(), + None => { + warn!("Missing dice option"); + if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + }, + None => { + warn!("Missing dice option"); + if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + }, + None => { + warn!("Missing dice option"); + if let Err(why) = edit_response(&ctx, &command, format!("Dice option is missing")).await { + error!("Failed to create response message: {}", why); + } + return; + } + }; + let dice = parse_dice(dice_string.as_str()); + match dice { + Ok((count, sides, modifier)) => { + let mut rolls = Vec::new(); + let mut total = 0; + for _ in 0..count { + let roll = rand::thread_rng().gen_range(1..=sides); + total += roll; + rolls.push(roll); + } + let response = format!("{}d{}{} = {}", + count, + sides, + if modifier > 0 { format!("+{}", modifier) } else if modifier < 0 { format!("-{}", modifier) } else { "".to_string() }, + total + (modifier as u32) + ); + if let Err(why) = edit_response(&ctx, &command, response).await { + error!("Failed to create response message: {}", why); + } + } + Err(why) => { + if let Err(why) = edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await { + error!("Failed to create response message: {}", why); + } + } + } + + +} + +fn parse_dice(dice: &str) -> Result<(u32, u32, i32), String> { + let mut parts = dice.split("d"); + let count = match parts.next() { + Some(c) => match c.parse::() { + Ok(n) => n, + Err(_) => return Err(format!("Invalid dice count: {}", c)) + }, + None => return Err(format!("Invalid dice string: {}", dice)) + }; + let mut positive_modifier = true; + let mut parts = match parts.next() { + Some(p) => { + // Check if contains a +/- modifier + if p.contains("+") { + positive_modifier = true; + p.split("+") + } else if p.contains("-") { + positive_modifier = false; + p.split("-") + } else { + p.split("+") + } + }, + None => return Err(format!("Invalid dice string: {}", dice)) + }; + let sides = match parts.next() { + Some(s) => match s.parse::() { + Ok(n) => { + if n == 4 || n == 6 || n == 8 || n == 10 || n == 12 || n == 20 || n == 100 { + n + } else { + return Err(format!("Invalid dice sides: {}", s)); + } + } + Err(_) => return Err(format!("Invalid dice sides: {}", s)) + }, + None => return Err(format!("Invalid dice string: {}", dice)) + }; + let modifier = match parts.next() { + Some(m) => match m.parse::() { + Ok(n) => { + if positive_modifier { + n + } else { + n * -1 + } + }, + Err(_) => return Err(format!("Invalid dice modifier: {}", m)) + }, + None => 0 + }; + Ok((count, sides, modifier)) +} + +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command.name("roll").description("Rolls D&D dice").create_option(|option| { + option + .name("dice") + .description("Dice to roll") + .kind(CommandOptionType::String) + .required(true) + }) +} \ No newline at end of file diff --git a/service/src/bot/handler.rs b/service/src/bot/handler.rs index 8057bb1..fef8a3b 100644 --- a/service/src/bot/handler.rs +++ b/service/src/bot/handler.rs @@ -49,6 +49,7 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { if let Interaction::ApplicationCommand(command) = interaction { match command.data.name.as_str() { + "roll" => commands::roll::run(&ctx, &command).await, "play" => commands::audio::play::run(&ctx, &command).await, "stop" => commands::audio::stop::run(&ctx, &command).await, "pause" => commands::audio::pause::run(&ctx, &command).await, @@ -80,7 +81,9 @@ impl EventHandler for Handler { volume: 100 }); let commands = guild.id.set_application_commands(&ctx.http, |commands| { - commands.create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) + commands + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::ping::register(command) }) + .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::roll::register(command) }) .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::play::register(command) }) .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::stop::register(command) }) .create_application_command(|command: &mut serenity::builder::CreateApplicationCommand| { commands::audio::pause::register(command) }) diff --git a/service/src/bot/mod.rs b/service/src/bot/mod.rs index 8f53b74..baed6f3 100644 --- a/service/src/bot/mod.rs +++ b/service/src/bot/mod.rs @@ -3,3 +3,4 @@ pub mod guilds; pub mod handler; pub mod messages; pub mod oai; +pub mod ytdlp; diff --git a/service/src/bot/ytdlp/mod.rs b/service/src/bot/ytdlp/mod.rs new file mode 100644 index 0000000..e544a7a --- /dev/null +++ b/service/src/bot/ytdlp/mod.rs @@ -0,0 +1,39 @@ +mod model; + +use std::process::{Child, Command, Output, Stdio}; + +pub use model::*; + + +const YOUTUBE_DL_COMMAND: &str = "yt-dlp"; + +pub struct YtDlp { + command: Command, + args: Vec, +} + +impl YtDlp { + pub fn new() -> Self { + let mut cmd = Command::new(YOUTUBE_DL_COMMAND); + cmd.env("LC_ALL", "en_US.UTF-8") + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .stderr(Stdio::piped()); + Self { + command: cmd, + args: Vec::new(), + } + } + + pub fn arg(&mut self, arg: &str) -> &mut Self { + self.args.push(arg.to_owned()); + self + } + + pub fn execute(&mut self) -> std::io::Result { + self.command + .args(self.args.clone()) + .spawn() + .and_then(Child::wait_with_output) + } +} \ No newline at end of file diff --git a/service/src/bot/ytdlp/model.rs b/service/src/bot/ytdlp/model.rs new file mode 100644 index 0000000..1d7ced9 --- /dev/null +++ b/service/src/bot/ytdlp/model.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + + +#[derive(Debug, Serialize, Deserialize)] +pub struct PlaylistItem { + pub id: String, + pub url: String, + pub title: String, + pub duration: i32, + pub playlist_index: i32, +} \ No newline at end of file diff --git a/service/src/lib.rs b/service/src/lib.rs index da781df..eb30dbd 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -54,6 +54,18 @@ impl fmt::Display for ServiceError { } } +impl From for ServiceError { + fn from(error: std::io::Error) -> ServiceError { + ServiceError::new(500, format!("Unknown io error: {}", error)) + } +} + +impl From for ServiceError { + fn from(error: std::string::FromUtf8Error) -> ServiceError { + ServiceError::new(500, format!("Unknown from utf8 error: {}", error)) + } +} + impl From for ServiceError { fn from(error: DieselError) -> ServiceError { match error {