diff --git a/Cargo.lock b/Cargo.lock index c1f18b1..2cd9db8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1002,6 +1002,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + [[package]] name = "fastrand" version = "2.3.0" @@ -1098,6 +1104,7 @@ dependencies = [ "sha3", "songbird", "structopt", + "symphonia", "tera", "tokio", "tonic", @@ -3885,8 +3892,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "815c942ae7ee74737bb00f965fa5b5a2ac2ce7b6c01c0cc169bbeaf7abd5f5a9" dependencies = [ "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-mkv", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e34f34298a7308d4397a6c7fbf5b84c5d491231ce3dd379707ba673ab3bd97" +dependencies = [ + "log", "symphonia-core", "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c01c2aae70f0f1fb096b6f0ff112a930b1fb3626178fba3ae68b09dce71706d4" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94e1feac3327cd616e973d5be69ad36b3945f16b06f19c6773fc3ac0b426a0f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f395a67057c2ebc5e84d7bb1be71cce1a7ba99f64e0f0f0e303a03f79116f89b" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-vorbis" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a98765fb46a0a6732b007f7e2870c2129b6f78d87db7987e6533c8f164a9f30" +dependencies = [ + "log", + "symphonia-core", + "symphonia-utils-xiph", ] [[package]] @@ -3902,6 +3972,43 @@ dependencies = [ "log", ] +[[package]] +name = "symphonia-format-mkv" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb43471a100f7882dc9937395bd5ebee8329298e766250b15b3875652fe3d6f" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada3505789516bcf00fc1157c67729eded428b455c27ca370e41f4d785bfa931" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f7be232f962f937f4b7115cbe62c330929345434c834359425e043bfd15f50" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "symphonia-metadata" version = "0.5.4" @@ -3914,6 +4021,16 @@ dependencies = [ "symphonia-core", ] +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484472580fa49991afda5f6550ece662237b00c6f562c7d9638d1b086ed010fe" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index 38afa0a..1186ff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ tera = "1.19.1" ndm = "0.9.10" regex = "1.10.2" magick_rust = "1.0.0" -songbird = "0.5.0" json = "0.12.4" axum = "0.8.1" sha3 = "0.10.8" @@ -35,3 +34,11 @@ log = "0.4.26" [dependencies.tokio] version = "1.35.1" features = ["macros", "rt-multi-thread"] + +[dependencies.songbird] +version = "0.5.0" +features = ["builtin-queue"] + +[dependencies.symphonia] +version = "0.5" +features = ["mp3", "wav"] diff --git a/src/config.rs b/src/config.rs index 0646b17..1352c01 100644 --- a/src/config.rs +++ b/src/config.rs @@ -58,14 +58,12 @@ impl BotConfig { #[derive(Debug)] pub struct BotState { pub accepted_nsfw: Option, - pub speak_lock: Mutex<()>, } impl BotState { pub async fn new() -> Result { Ok(Self { accepted_nsfw: None, - speak_lock: Mutex::new(()), }) } } @@ -76,6 +74,7 @@ pub struct GlobalData { pub bot_state: Mutex, pub db: Database, pub picox: AlbumManager, + pub speak_lock: Mutex<()>, } impl GlobalData { @@ -100,6 +99,7 @@ impl GlobalData { db, cfg: cfg.clone(), picox: AlbumManager::new(cfg.picox.api_base_url, &cfg.picox.token), + speak_lock: Default::default(), }) } } diff --git a/src/discord/mod.rs b/src/discord/mod.rs index 042ddb5..fe45eb7 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -9,6 +9,7 @@ mod joke; mod little_fren; mod motivate; pub(crate) mod shop; +pub(crate) mod voices; use crate::config::GlobalData; use crate::error::Error; @@ -24,6 +25,7 @@ use poise::serenity_prelude::{GuildId, Http, Message, ReactionType, RoleId}; use poise::{find_command, serenity_prelude as serenity, FrameworkOptions}; use rand::prelude::IteratorRandom; use rand::rng; +use songbird::SerenityInit; pub type Context<'a> = poise::Context<'a, Arc, Error>; @@ -207,6 +209,9 @@ pub async fn run_bot(global_data: GlobalData) { shop::sell_item(), shop::shop(), shop::use_item(), + voices::list_voices(), + voices::list_words(), + voices::say(), ], event_handler: |ctx, event, framework, data| { Box::pin(event_handler(ctx, event, framework, data)) @@ -234,6 +239,7 @@ pub async fn run_bot(global_data: GlobalData) { let mut client = serenity::ClientBuilder::new(token, intents) .framework(framework) + .register_songbird() .await .unwrap(); diff --git a/src/discord/voices.rs b/src/discord/voices.rs index d22217c..2657102 100644 --- a/src/discord/voices.rs +++ b/src/discord/voices.rs @@ -1,23 +1,21 @@ -use crate::{command, group, GlobalData}; -use serenity::all::CreateAttachment; -use serenity::builder::CreateMessage; -use serenity::client::Context; -use serenity::framework::standard::{Args, CommandResult}; -use serenity::model::channel::Message; -use serenity::model::id::UserId; -use serenity::model::prelude::GuildId; -use serenity::utils::MessageBuilder; +use crate::discord::Context; +use crate::error::Error; +use magick_rust::bindings::wchar_t; +use poise::async_trait; +use poise::serenity_prelude::all::CreateAttachment; +use poise::serenity_prelude::builder::CreateMessage; +use poise::serenity_prelude::model::channel::Message; +use poise::serenity_prelude::model::id::UserId; +use poise::serenity_prelude::model::prelude::GuildId; +use poise::serenity_prelude::utils::MessageBuilder; use songbird::driver::Bitrate; -use songbird::input; use songbird::input::cached::Compressed; +use songbird::{input, Event, EventContext, EventHandler, Songbird, TrackEvent}; use std::borrow::Cow; use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::path::{Path, PathBuf}; - -#[group] -#[commands(say, list_words, list_voices)] -pub struct Voices; +use std::sync::Arc; async fn get_voice_dictionary(path: &Path) -> Result, tokio::io::Error> { let mut dir = tokio::fs::read_dir(path).await?; @@ -85,7 +83,7 @@ pub enum VoiceError { VoiceNotFound(String), WordNotFound(String), NotInVoiceChannel, - Serenity(serenity::Error), + Serenity(poise::serenity_prelude::Error), } impl Display for VoiceError { @@ -99,30 +97,44 @@ impl Display for VoiceError { } } -impl From for VoiceError { - fn from(value: serenity::Error) -> Self { +impl From for VoiceError { + fn from(value: poise::serenity_prelude::Error) -> Self { Self::Serenity(value) } } impl std::error::Error for VoiceError {} +struct LeaveHandler { + guild: GuildId, + manager: Arc, +} + +#[async_trait] +impl EventHandler for LeaveHandler { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(_track) = ctx { + let has_handler = self.manager.get(self.guild).is_some(); + + if has_handler { + let _ = self.manager.remove(self.guild).await.is_ok(); + } + } + + None + } +} + pub async fn speak( - ctx: &Context, + ctx: Context<'_>, guild_id: GuildId, user_id: UserId, voice: &str, phrase: &str, ) -> Result<(), VoiceError> { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); + let _ = ctx.data().speak_lock.lock(); - let _ = global_data.bot_state.speak_lock.lock().await; - - let voice_path = match find_voice(&global_data.cfg.voice_path, voice) - .await - .unwrap() - { + let voice_path = match find_voice(&ctx.data().cfg.voice_path, voice).await.unwrap() { None => return Err(VoiceError::VoiceNotFound(voice.to_string())), Some(voice_path) => voice_path, }; @@ -160,7 +172,7 @@ pub async fn speak( } let channel_id = { - let guild = ctx.cache.guild(guild_id).unwrap(); + let guild = ctx.cache().guild(guild_id).unwrap(); let channel_id = guild .voice_states @@ -177,7 +189,7 @@ pub async fn speak( } }; - let manager = songbird::get(ctx) + let manager = songbird::get(ctx.serenity_context()) .await .expect("Songbird not initialized") .clone(); @@ -186,8 +198,8 @@ pub async fn speak( if let Ok(handler_lock) = handler_lock { let mut handler = handler_lock.lock().await; - for word in sentence { - let word_path = dict.get(&word).cloned().unwrap(); + for (ndx, word) in sentence.iter().enumerate() { + let word_path = dict.get(word).cloned().unwrap(); let src = input::File::new(word_path); @@ -197,34 +209,44 @@ pub async fn speak( let _ = audio_src.raw.spawn_loader(); - let voice = handler.play_input(audio_src.into()); - voice.set_volume(0.5).unwrap(); + let handle = handler.enqueue_input(audio_src.into()).await; + handle.set_volume(0.50).unwrap(); - //tokio::time::sleep(duration).await; + if ndx == sentence.len() - 1 { + // add task to disconnect bot + + handle + .add_event( + Event::Track(TrackEvent::End), + LeaveHandler { + guild: ctx.guild_id().unwrap(), + manager: manager.clone(), + }, + ) + .unwrap(); + } } - - handler.leave().await.unwrap(); } Ok(()) } -#[command] -#[only_in(guilds)] -#[min_args(1)] -async fn say(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let guild_id = msg.guild_id.unwrap(); +#[poise::command(prefix_command, category = "Voice", guild_only)] +pub async fn say( + ctx: Context<'_>, + #[description = "Voice to use"] voice: String, + #[description = "message to say"] + #[rest] + phrase: String, +) -> Result<(), Error> { + let guild_id = ctx.guild_id().unwrap(); - let voice = args.parse::()?; - args.advance(); - let phrase = args.rest(); - - if let Err(err) = speak(ctx, guild_id, msg.author.id, &voice, phrase).await { + if let Err(err) = speak(ctx, guild_id, ctx.author().id, &voice, &phrase).await { match err { VoiceError::VoiceNotFound(_) | VoiceError::WordNotFound(_) | VoiceError::NotInVoiceChannel => { - msg.reply(&ctx.http, format!("Error: {}", err)).await?; + ctx.reply(format!("Error: {}", err)).await?; } _ => return Err(err.into()), } @@ -233,18 +255,14 @@ async fn say(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { Ok(()) } -#[command] -#[only_in(guilds)] -#[min_args(1)] -async fn list_words(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); - - let voice = args.parse::().unwrap(); - - match find_voice(&global_data.cfg.voice_path, &voice).await? { +#[poise::command(prefix_command, category = "Voice", guild_only)] +pub async fn list_words( + ctx: Context<'_>, + #[description = "Get all words for this voice"] voice: String, +) -> Result<(), Error> { + match find_voice(&ctx.data().cfg.voice_path, &voice).await? { None => { - msg.reply(&ctx.http, format!("No voice found called '{}'", voice)) + ctx.reply(format!("No voice found called '{}'", voice)) .await?; } Some(voice_path) => { @@ -265,9 +283,9 @@ async fn list_words(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let file_data = file_data.as_bytes(); let file_data = Cow::from(file_data); - msg.channel_id + ctx.channel_id() .send_message( - &ctx.http, + &ctx, CreateMessage::new() .add_file(CreateAttachment::bytes(file_data, "words.txt".to_string())), ) @@ -278,20 +296,16 @@ async fn list_words(ctx: &Context, msg: &Message, args: Args) -> CommandResult { Ok(()) } -#[command] -#[only_in(guilds)] -async fn list_voices(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); - +#[poise::command(prefix_command, category = "Voice", guild_only)] +pub async fn list_voices(ctx: Context<'_>) -> Result<(), Error> { let mut voice_message = MessageBuilder::new(); - for voice in get_voices(&global_data.cfg.voice_path).await? { + for voice in get_voices(&ctx.data().cfg.voice_path).await? { voice_message.push("* "); voice_message.push_line(voice); } - msg.reply(&ctx.http, voice_message.build()).await?; + ctx.reply(voice_message.build()).await?; Ok(()) } diff --git a/src/error.rs b/src/error.rs index c7c1773..fca2f38 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use crate::discord::voices::VoiceError; use crate::user; use magick_rust::MagickError; use serde::ser::StdError; @@ -19,6 +20,7 @@ pub enum Error { MagickError(magick_rust::MagickError), CommandError(String), IoError(std::io::Error), + VoiceError(VoiceError), } impl StdError for Error {} @@ -71,6 +73,12 @@ impl From for Error { } } +impl From for Error { + fn from(value: VoiceError) -> Self { + Self::VoiceError(value) + } +} + impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -86,6 +94,7 @@ impl Display for Error { Error::MagickError(err) => write!(f, "ImageMagick error: {}", err), Error::CommandError(err_msg) => write!(f, "{}", err_msg), Error::IoError(err) => write!(f, "IO Error: {}", err), + Error::VoiceError(err) => write!(f, "Voice error: {}", err), } } }