diff --git a/.TODO b/.TODO new file mode 100644 index 0000000..f6e1060 --- /dev/null +++ b/.TODO @@ -0,0 +1,2 @@ +handle disconnect by someone, other than command +disconnect after some time when not playing \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c549b2..f50483c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ # IDE config .vscode -mooverdb.db* \ No newline at end of file +mooverdb.db* + +*.txt \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 3ad93ca..ae57159 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "tenor-v2"] path = tenor-v2 url = ssh://git@forgejo.djkato.net:222/HLadislav/tenor-v2.git +[submodule "radiobrowser"] + path = radiobrowser + url = ./radiobrowser diff --git a/Cargo.toml b/Cargo.toml index 1a16780..c557b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,35 @@ [package] name = "moover_rust" -version = "0.3.0" +version = "3.3.1" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] rand = "0.8.5" -anyhow = "1.0.89" +anyhow = "1.0.95" tokio-cron-scheduler = "0.13.0" dotenv = "0.15.0" poise = "0.6.1" serenity = { version = "0.12.4", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "http", "cache"] } # serenity_utils = "0.7.0" -tokio = { version = "1.42.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] } regex = "1.11.1" -chrono = "0.4.38" -sqlx = {version="0.8.2", features=["runtime-tokio", "sqlite"]} +chrono = "0.4.39" +sqlx = {version="0.8.3", features=["runtime-tokio", "sqlite"]} form_urlencoded = "1.2.1" tenorv2 = { path = "./tenor-v2/tenorv2" } +# librespot = { version = "0.6.0", default-features = false, features = ["rodio-backend"] } +songbird = { version = "0.4.6", features = ["driver", "builtin-queue"] } +reqwest = "0.11.27" # songbird depends on ^0.11 +# radiobrowser = "0.6.1" +radiobrowser = { path = "./radiobrowser" } + +[dependencies.symphonia] +version = "0.5.2" +features = ["aac", "mp3", "isomp4", "alac"] [features] DEBUG = [] RELEASE = [] -GUILD_COMMAND = [] \ No newline at end of file +GUILD_COMMAND = [] diff --git a/makefile b/makefile index 7815468..ad262f5 100644 --- a/makefile +++ b/makefile @@ -1,12 +1,11 @@ dev: - cargo build --features DEBUG + cargo build --features DEBUG,GUILD_COMMAND release: cargo build --release --features RELEASE -run: - cargo build --features DEBUG +run: dev ./target/debug/moover_rust -run_rel: +run_rel: release ./target/release/moover_rust \ No newline at end of file diff --git a/radiobrowser b/radiobrowser new file mode 160000 index 0000000..0786c89 --- /dev/null +++ b/radiobrowser @@ -0,0 +1 @@ +Subproject commit 0786c897260c7f180917af101ae5ea686cb60c2e diff --git a/src/commands/mod.rs b/src/commands.rs similarity index 50% rename from src/commands/mod.rs rename to src/commands.rs index 39b9bfb..6a1c0f7 100644 --- a/src/commands/mod.rs +++ b/src/commands.rs @@ -1,11 +1,13 @@ pub use moover::*; pub use notice::*; -pub use other::*; pub use user_interactions::*; +// pub use other::*; +pub use voice::*; +// pub use command_utils::*; pub mod moover; pub mod notice; pub mod user_interactions; -pub mod other; - -// pub mod channel_test; \ No newline at end of file +// pub mod other; +pub mod voice; +// mod command_utils; diff --git a/src/commands/moover/mod.rs b/src/commands/moover.rs similarity index 64% rename from src/commands/moover/mod.rs rename to src/commands/moover.rs index fbf6377..f2c99f7 100644 --- a/src/commands/moover/mod.rs +++ b/src/commands/moover.rs @@ -1,7 +1,7 @@ pub use moove::*; +pub use gif::*; pub use say::*; -// pub use gif::*; pub mod moove; +pub mod gif; pub mod say; -// pub mod gif; \ No newline at end of file diff --git a/src/commands/moover/gif.rs b/src/commands/moover/gif.rs new file mode 100644 index 0000000..4861a37 --- /dev/null +++ b/src/commands/moover/gif.rs @@ -0,0 +1,20 @@ +use poise; +use serenity::all::{Embed, User}; + +use crate::types::{Error, Context}; + +#[poise::command( + slash_command, + description_localized("en-US", "Send a gif from Tenor") +)] +pub async fn gif(ctx: Context<'_>, + #[description = "What should I search?"] + what: String, + #[description = "Should the gif be R-rated?"] + contentfilter: String +) -> Result<(), Error> { + // let embed; + // send_with_embed(ctx, "hug", &title, &desc).await?; + ctx.reply("Done!").await?; + Ok(()) +} \ No newline at end of file diff --git a/src/commands/moover/say.rs b/src/commands/moover/say.rs index c097539..e4df757 100644 --- a/src/commands/moover/say.rs +++ b/src/commands/moover/say.rs @@ -1,8 +1,6 @@ use poise; -// use super::super::types::Data; -type Error = Box; -type Context<'a> = poise::Context<'a, (), Error>; +use crate::types::{Context, Error}; #[poise::command( slash_command, diff --git a/src/commands/notice/mod.rs b/src/commands/notice.rs similarity index 78% rename from src/commands/notice/mod.rs rename to src/commands/notice.rs index d63767d..33bed17 100644 --- a/src/commands/notice/mod.rs +++ b/src/commands/notice.rs @@ -2,4 +2,4 @@ pub use birthday::*; pub use events::*; pub mod birthday; -pub mod events; \ No newline at end of file +pub mod events; diff --git a/src/commands/user_interactions/mod.rs b/src/commands/user_interactions.rs similarity index 85% rename from src/commands/user_interactions/mod.rs rename to src/commands/user_interactions.rs index 0e8654b..f9a7882 100644 --- a/src/commands/user_interactions/mod.rs +++ b/src/commands/user_interactions.rs @@ -3,4 +3,4 @@ pub use hug::*; pub mod interaction; pub mod headpat; -pub mod hug; \ No newline at end of file +pub mod hug; diff --git a/src/commands/user_interactions/interaction.rs b/src/commands/user_interactions/interaction.rs index 6db121a..d53002e 100644 --- a/src/commands/user_interactions/interaction.rs +++ b/src/commands/user_interactions/interaction.rs @@ -2,7 +2,7 @@ use anyhow::anyhow; use serenity::all::{Colour, CreateEmbed, CreateMessage}; use tenorv2::tenor_builder::Tenor; -use crate::{types::Context, util::{gifs::get_random_tenor_gif, utilities}}; +use crate::{types::Context, utils::{gifs::get_random_tenor_gif, utilities}}; /// Sends embed with random tenor gif from searched query /// title and desc are used in the embed @@ -34,7 +34,3 @@ pub(super) async fn send_with_embed(ctx: Context<'_>, query: &str, title: &str, Ok(()) } - -pub(super) async fn send(ctx: Context<'_>, msg: &str) -> anyhow::Result<()> { - Ok(()) -} \ No newline at end of file diff --git a/src/commands/voice.rs b/src/commands/voice.rs new file mode 100644 index 0000000..a86fe29 --- /dev/null +++ b/src/commands/voice.rs @@ -0,0 +1,12 @@ +use voice_utils::*; + +mod voice_utils; +pub mod player_common; +pub mod radio; +pub mod general_player; +pub mod voice_types; + +// ! not working +// pub mod yt; +// TODO implement +// pub mod spotify; diff --git a/src/commands/voice/general_player.rs b/src/commands/voice/general_player.rs new file mode 100644 index 0000000..3ad9b7b --- /dev/null +++ b/src/commands/voice/general_player.rs @@ -0,0 +1,60 @@ +use std::vec; + +use songbird::input::YoutubeDl; + +use crate::types::{Context, ContextExt, Error}; +use crate::commands::voice::voice_utils::autocomplete_channel; + +use super::connect; + +// TODO: search, queue +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from supported URL") +)] +pub async fn play(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let http_client = &ctx.data().http_client; + + if manager.get(guild_id).is_none() { + if let Err(e) = connect(&ctx, guild_id, channel).await { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let src = YoutubeDl::new(http_client.clone(), url); + handler.enqueue_input(src.into()).await; + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} diff --git a/src/commands/voice/player_common.rs b/src/commands/voice/player_common.rs new file mode 100644 index 0000000..ed92390 --- /dev/null +++ b/src/commands/voice/player_common.rs @@ -0,0 +1,162 @@ +use std::vec; + +use poise::CreateReply; +use songbird::input::{File, Input}; + +use crate::utils::debug::send_error; +use crate::types::{Context, ContextExt, Error}; +use crate::utils::utilities::get_local_songs; + +use super::voice_utils::{connect, autocomplete_channel}; + +/** + * Common commands that are the same for every implementation + */ + +#[poise::command( + slash_command, + description_localized("en-US", "Disconnect from voice channel") +)] +pub async fn disconnect( + ctx: Context<'_> +) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let has_handler = manager.get(guild_id).is_some(); + + if ! has_handler { + ctx.reply("I am not connected to a channel!").await?; + return Ok(()) + } + + match manager.remove(guild_id).await { + Ok(()) => { + ctx.reply_ephemeral("Disconnected").await?; + return Ok(()) + } + Err(e) => { + let _ = send_error(ctx.serenity_context().http.clone(), e.to_string()).await; + return Ok(()) + } + } +} + +#[allow(dead_code)] +async fn autocomplete_song( + _ctx: Context<'_>, + partial: &str, +) -> Vec { + + match get_local_songs(partial) { + Ok(names) => names, + Err(_) => vec![] + } +} + +#[poise::command( + slash_command, + description_localized("en-US", "Play song from server storage") +)] +pub async fn play_local(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[autocomplete = "autocomplete_song"] + #[description = "Filename of local song: "] + file_name: String +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let input_file = File::new(format!("/home/emil/Music/{file_name}")); + let input = Input::Lazy(Box::new(input_file)); + let _ = handler.play_only_input(input); + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + + +#[poise::command( + slash_command, + description_localized("en-US", "Display currently playing info") +)] +pub async fn playing(ctx: Context<'_>) -> Result<(), Error> { + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(_) = manager.get(guild_id) else { + ctx.reply_ephemeral("I am not connected to any voice channel right now").await?; + return Ok(()) + }; + + // println!("here"); + + let embed = { + let mutex_hashmap = ctx.data().playing_info.lock().await; + let Some(playing_info) = mutex_hashmap.get(&guild_id) else { + ctx.reply_ephemeral("Entry not found, try to reconnect me").await?; + return Ok(()); + }; + + playing_info.generate_embed().await + }; + + dbg!(&embed); + + ctx.send( + CreateReply::default() + .embed(embed) + ).await?; + + Ok(()) +} + diff --git a/src/commands/voice/radio.rs b/src/commands/voice/radio.rs new file mode 100644 index 0000000..0ea2063 --- /dev/null +++ b/src/commands/voice/radio.rs @@ -0,0 +1,6 @@ +pub use radio_player::*; +use radio_utils::*; + +pub mod radio_player; + +mod radio_utils; diff --git a/src/commands/voice/radio/radio_player.rs b/src/commands/voice/radio/radio_player.rs new file mode 100644 index 0000000..f33b54a --- /dev/null +++ b/src/commands/voice/radio/radio_player.rs @@ -0,0 +1,180 @@ +use std::vec; + +use poise::ChoiceParameter; +use reqwest::Client; + +use songbird::input::Input; +use songbird::input::HttpRequest; + +use super::super::connect; +use super::link_or_string; +use super::LinkString; +use super::parse_radio_autocomplete; +use super::radio_utils::paginate_search_stations; +use super::super::voice_utils::MAX_ENTRIES; + +use crate::commands::voice_types::{NumberOfEntries, PlayingInfoType}; +use crate::types::{Context, Error, ContextExt}; +use crate::commands::voice::voice_utils::autocomplete_channel; + +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from URL source"), + subcommands("search", "play") +)] +pub async fn radio(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} + +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from URL source") +)] +pub async fn play(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[autocomplete = "autocomplete_radio"] + #[description = "Radio station: "] + name: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let api = &ctx.data().radio_browser; + + let stations_result = match link_or_string(&name) { + LinkString::Link => { + if name.ends_with(".m3u") { + ctx.reply_ephemeral("Sorry, currently I don't support m3u files").await?; + return Ok(()); + } + + api.get_stations().byurl(name).await + }, + LinkString::String => { + let (name, country, language) = match parse_radio_autocomplete(&name) { + Some(tuple) => tuple, + None => { + ctx.reply_ephemeral("Please use either direct URL or provided autocomplete").await?; + return Ok(()) + } + }; + + api.get_stations() + .name_exact(true) + .name(name) + .country_exact(true) + .country(country) + .language_exact(true) + .language(language) + .send().await + } + }; + + let Ok(stations) = stations_result else { + ctx.reply_ephemeral("There was an error with radio API!").await?; + return Ok(()) + }; + + let Some(station) = stations.first() else { + ctx.reply_ephemeral("Radio station not found!").await?; + return Ok(()) + }; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + if manager.get(guild_id).is_none() { + if let Err(e) = connect(&ctx, guild_id, channel).await { + ctx.reply_ephemeral(&e.to_string()).await?; + println!("SONGBIRD MANAGER ERROR: {}", e.to_string()); + return Ok(()) + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let client = Client::new(); + let request = HttpRequest::new(client, station.url.clone()); + let input = Input::from(request); + handler.play_only_input(input); + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + { + let mut hash_map = ctx.data().playing_info.lock().await; + hash_map.insert(guild_id, PlayingInfoType::RadioInfo(station.clone())); + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} + +async fn autocomplete_radio( + ctx: Context<'_>, + partial: &str +) -> Vec { + + let api = &ctx.data().radio_browser; + + let stations: Vec = match api.get_stations() + .limit(MAX_ENTRIES) + .name(partial) + .hidebroken(true) + .send().await { + Ok(stations) => { + stations.iter().map(|station: &radiobrowser::ApiStation| + format!("Name: {} Country: {} Language: {}", station.name, station.country, station.language) + ).collect() + }, + Err(_) => return vec!["".to_owned()] + }; + + return stations +} + +#[poise::command( + slash_command, + description_localized("en-US", "Search for a radio") +)] +pub async fn search(ctx: Context<'_>, + #[description = "Radio station: "] + name: String, + #[description = "Country: "] + country: Option, + #[description = "Tag: "] + tag: Option, + #[description = "Number of entries on page: "] + entries: Option +) -> Result<(), Error> { + + let api = &ctx.data().radio_browser; + + let entries = entries.unwrap_or(NumberOfEntries::Ten); + + let search_builder = api.get_stations() + .limit(entries.name()) + .name(name) + .tag(tag.unwrap_or("".to_owned())) + .country(country.unwrap_or("".to_owned())) + .hidebroken(true); + + paginate_search_stations(&ctx, &search_builder, entries).await?; + + Ok(()) +} diff --git a/src/commands/voice/radio/radio_utils.rs b/src/commands/voice/radio/radio_utils.rs new file mode 100644 index 0000000..c207dfc --- /dev/null +++ b/src/commands/voice/radio/radio_utils.rs @@ -0,0 +1,143 @@ +use poise::CreateReply; +use radiobrowser::{ApiStation, StationSearchBuilder}; +use regex::Regex; +use serenity::all::{CreateActionRow, CreateButton, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage}; + +use crate::{commands::voice_types::NumberOfEntries, types::Context}; + +pub async fn paginate_search_stations( + ctx: &Context<'_>, + search_builder: &StationSearchBuilder, + limit: NumberOfEntries +) -> Result<(), serenity::Error> { + // Define some unique identifiers for the navigation buttons + let ctx_id = ctx.id(); + let prev_button_id = format!("{}prev", ctx_id); + let next_button_id = format!("{}next", ctx_id); + + let search_builder = search_builder; + + let Ok(stations) = search_builder.clone().send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + + let mut page = 0; + + let embed = create_station_list_embed(&stations, page); + + // Send the embed with the first page as content + let reply = { + let components = CreateActionRow::Buttons(vec![ + CreateButton::new(&prev_button_id).emoji('◀'), + CreateButton::new(&next_button_id).emoji('▶'), + ]); + + CreateReply::default() + .embed(embed) + .components(vec![components]) + }; + + ctx.send(reply).await?; + + // Loop through incoming interactions with the navigation buttons + let mut offset = 0; + let limit_int = limit as u32; + while let Some(press) = serenity::collector::ComponentInteractionCollector::new(ctx) + // We defined our button IDs to start with `ctx_id`. If they don't, some other command's + // button was pressed + .filter(move |press| press.data.custom_id.starts_with(&ctx_id.to_string())) + // Timeout when no navigation button has been pressed for 24 hours + .timeout(std::time::Duration::from_secs(3600 * 24)) + .await + { + // Depending on which button was pressed, go to next or previous page + if press.data.custom_id == next_button_id { + offset += limit_int; + page += 1; + } else if press.data.custom_id == prev_button_id { + offset = if offset < limit_int { offset } else { offset - limit_int }; + page = if page == 0 { 0 } else { page - 1 }; + } else { + // This is an unrelated button interaction + continue; + } + + let Ok(mut stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + + if stations.is_empty() { + offset = 0; + page = 0; + + let Ok(new_stations) = search_builder.clone().offset(offset.to_string()).send().await else { + ctx.reply("Something went wrong, try searching again").await?; + return Ok(()) + }; + stations = new_stations; + } + + let embed = create_station_list_embed(&stations, page); + + // Update the message with the new page contents + press + .create_response( + ctx.serenity_context(), + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(embed), + ), + ) + .await?; +} + +Ok(()) +} + +fn create_station_list_embed(stations: &Vec, page: u32) -> CreateEmbed { + let result = CreateEmbed::new() + .fields( + stations.iter().map(|station| { + ( + station.name.clone(), + format!("Country: {} Stream: {}", + station.country, station.url), + false + ) + }) + ) + .footer(CreateEmbedFooter::new(format!("Page: {}", page + 1))); + + result +} + +pub enum LinkString { + Link, + String +} + +pub fn link_or_string(haystack: &str) -> LinkString { + let Ok(re) = Regex::new(r"^https?://([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$") else { + panic!("Wrong regex expression!"); + }; + + return if re.is_match(haystack) { LinkString::Link } else { LinkString::String } +} + +pub fn parse_radio_autocomplete(haystack: &str) -> Option<(String, String, String)> { + let Ok(re) = Regex::new(r"^Name: (.*) Country: (.*) Language: (.*)") else { + panic!("Wrong regex expression!"); + }; + + let Some(captures) = re.captures(haystack) else { + return None + }; + + return Some(( + String::from(&captures[1]), + String::from(&captures[2]), + String::from(&captures[3]) + )) +} diff --git a/src/commands/voice/spotify.rs b/src/commands/voice/spotify.rs new file mode 100644 index 0000000..f73c81c --- /dev/null +++ b/src/commands/voice/spotify.rs @@ -0,0 +1,3 @@ +pub use spotify_player::*; + +pub mod spotify_player; diff --git a/src/commands/other/mod.rs b/src/commands/voice/spotify/spotify.rs similarity index 100% rename from src/commands/other/mod.rs rename to src/commands/voice/spotify/spotify.rs diff --git a/src/util/embeds.rs b/src/commands/voice/spotify/spotify_player.rs similarity index 100% rename from src/util/embeds.rs rename to src/commands/voice/spotify/spotify_player.rs diff --git a/src/commands/voice/voice_types.rs b/src/commands/voice/voice_types.rs new file mode 100644 index 0000000..60fe865 --- /dev/null +++ b/src/commands/voice/voice_types.rs @@ -0,0 +1,91 @@ +use poise::ChoiceParameter; +use radiobrowser::ApiStation; +use serenity::{all::CreateEmbed, async_trait}; + +use crate::utils::utilities::hash_to_u32; + +#[derive(ChoiceParameter, Debug)] +pub enum NumberOfEntries { + #[name = "5"] + Five = 5, + #[name = "10"] + Ten = 10, + #[name = "15"] + Fifteen = 15 +} + +#[async_trait] +pub trait GenerateEmbed { + async fn generate_embed(&self) -> CreateEmbed; +} + +#[allow(dead_code)] +pub struct SpotifyInfo { + pub name: String, + pub interpret: String, + // client + pub duration: String, +} + +#[allow(dead_code)] +pub struct GeneralInfo { + pub name: String, + pub site: String, + // client + pub duration: Option +} + + +#[async_trait] +impl GenerateEmbed for GeneralInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for SpotifyInfo { + async fn generate_embed(&self) -> CreateEmbed { + CreateEmbed::new() + } +} + +#[async_trait] +impl GenerateEmbed for ApiStation { + async fn generate_embed(&self) -> CreateEmbed { + let embed= CreateEmbed::new() + .title("Current station:") + .thumbnail(&self.favicon) + .field("Name:", &self.name, true) + .field("Country:", &self.country, true) + .field("Language:", &self.language, true) + .field("Website:", &self.homepage, true) + .field("Stream:", &self.url, true) + .field("Tags:", &self.tags, false) + .color(hash_to_u32(&self.stationuuid)); + + embed + } +} + +// pub struct PlayingInfo { +// pub data: T, +// pub create_embed: Box CreateEmbed + Send + Sync> +// } + +#[allow(dead_code)] +pub enum PlayingInfoType { + SpotifyInfo(SpotifyInfo), + RadioInfo(ApiStation), + GeneralInfo(GeneralInfo) +} + +impl PlayingInfoType { + pub async fn generate_embed(&self) -> CreateEmbed { + match self { + PlayingInfoType::SpotifyInfo(v) => v.generate_embed().await, + PlayingInfoType::RadioInfo(v) => v.generate_embed().await, + PlayingInfoType::GeneralInfo(v) => v.generate_embed().await + } + } +} diff --git a/src/commands/voice/voice_utils.rs b/src/commands/voice/voice_utils.rs new file mode 100644 index 0000000..45f490b --- /dev/null +++ b/src/commands/voice/voice_utils.rs @@ -0,0 +1,78 @@ +use serenity::all::{ChannelId, GuildId}; +use serenity::async_trait; + +use songbird::events::{Event, EventContext, EventHandler as VoiceEventHandler}; +use songbird::TrackEvent; + +use crate::{types::{Context, Error}, utils::utilities::get_channel_by_name}; + +pub const MAX_ENTRIES: &str = "15"; + +/// Returns either voice channel to which the user is currently connected to or the one passed via name +async fn get_voice_channel(ctx: &Context<'_>, name: Option) -> Result { + if name.is_none() || name.as_ref().is_some_and(|n| n.is_empty()) { + match ctx.guild().and_then(|guild| + guild.voice_states.get(&ctx.author().id).and_then(|voice_state| + voice_state.channel_id + ) + ) { + Some(c) => Ok(c), + None => Err("You must be in a voice channel or specify explicit voice channel by name".to_string()) + } + } + else { + match ctx.guild().and_then(|guild| + get_channel_by_name(guild, name.unwrap()) + ) { + Some(c) => Ok(c), + None => Err("Channel with this name does not exist".to_string()) + } + } +} + +struct TrackErrorNotifier; + +#[async_trait] +impl VoiceEventHandler for TrackErrorNotifier { + async fn act(&self, ctx: &EventContext<'_>) -> Option { + if let EventContext::Track(track_list) = ctx { + for (state, handle) in *track_list { + println!( + "Track {:?} encountered an error: {:?}", + handle.uuid(), + state.playing + ); + } + } + + None + } +} + +pub async fn connect(ctx: &Context<'_>, guild_id: GuildId, channel: Option) -> Result<(), Error> { + let voice_channel = get_voice_channel(&ctx, channel).await?; + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Ok(handler_lock) = manager.join(guild_id, voice_channel).await { + let mut handler = handler_lock.lock().await; + handler.add_global_event(TrackEvent::Error.into(), TrackErrorNotifier); + } + + Ok(()) +} + +pub async fn autocomplete_channel( + ctx: Context<'_>, + _partial: &str, +) -> Vec { + + use crate::utils::utilities::get_vc_names; + match ctx.guild() { + Some(guild) => get_vc_names(guild), + None => [].to_vec() + } +} diff --git a/src/commands/voice/yt.rs b/src/commands/voice/yt.rs new file mode 100644 index 0000000..af9e888 --- /dev/null +++ b/src/commands/voice/yt.rs @@ -0,0 +1,3 @@ +pub use yt_player::*; + +pub mod yt_player; diff --git a/src/commands/voice/yt/yt_player.rs b/src/commands/voice/yt/yt_player.rs new file mode 100644 index 0000000..ff5098b --- /dev/null +++ b/src/commands/voice/yt/yt_player.rs @@ -0,0 +1,88 @@ +use std::vec; +use std::env; + +use songbird::input::YoutubeDl; + +use crate::commands::util::connect; +use crate::util::poise_context_extension::ContextExt; +use crate::types::{Context, Error}; +use crate::commands::voice::util::autocomplete_channel; + +// TODO: search, queue +#[poise::command( + slash_command, + description_localized("en-US", "Plays music from YouTube URL") +)] +pub async fn play_yt(ctx: Context<'_>, + #[autocomplete = "autocomplete_channel"] + #[description = "Voice channel name: "] + channel: Option, + #[description = "Source URL: "] + url: String, +) -> Result<(), Error> { + + if ctx.guild().is_none() { + ctx.reply_ephemeral("Can't use this outside of guild").await?; + return Ok(()); + } + + let manager = songbird::get(ctx.serenity_context()) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let Some(guild_id) = ctx.guild_id() else { + ctx.reply_ephemeral("Guild id not found").await?; + return Ok(()) + }; + + let http_client = ctx.data().http_client.clone(); + + if manager.get(guild_id).is_none() { + match connect(&ctx, guild_id, channel).await { + Ok(_) => (), + Err(e) => { + ctx.reply_ephemeral(&e.to_string()).await?; + return Ok(()) + } + } + } + + if let Some(handler_lock) = manager.get(guild_id) { + let mut handler = handler_lock.lock().await; + + let cookies_path = match env::var("COOKIES") { + Ok(path) => path, + Err(e) => { + let _ = ctx.reply_ephemeral("There was an error (did not find cookies)").await; + dbg!(e); + return Ok(()); + } + }; + + let po_token = match env::var("PO_TOKEN") { + Ok(token) => token, + Err(e) => { + let _ = ctx.reply_ephemeral("There was an error (did not find token)").await; + dbg!(e); + return Ok(()); + } + }; + + let arguments: Vec = vec![ + "--extractor-args".into(), + format!("youtube:player-client=web,default;po_token=web+{po_token}"), + "--cookies".into(), + cookies_path + ]; + let src = YoutubeDl::new(http_client, url).user_args(arguments); + handler.enqueue_input(src.into()).await; + } + else { + ctx.reply_ephemeral("Not in a voice channel").await?; + return Ok(()) + } + + ctx.reply_ephemeral("Done!").await?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 59da021..417f31c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -5,35 +6,38 @@ use std::time::Duration; use std::error; use std::env; -use poise::samples::register_globally; - -use poise::samples::register_in_guild; use serenity::async_trait; +use serenity::futures::lock::Mutex; use serenity::prelude::GatewayIntents; use serenity::client::Context; use serenity::model::gateway::Ready; use serenity::all::{EventHandler, Message}; use serenity::Client; +use reqwest::Client as HttpClient; + use dotenv::dotenv; +use songbird::SerenityInit; use tokio_cron_scheduler::{JobScheduler, Job}; +use radiobrowser::RadioBrowserAPI; + mod message_handler; use message_handler::handle; mod commands; -mod util; +mod utils; mod other; use other::notice; mod types; -use types::Error; +use types::{Data, Error}; struct Handler; -async fn on_error(error: poise::FrameworkError<'_, (), Error>) { +async fn on_error(error: poise::FrameworkError<'_, Data, Error>) { match error { poise::FrameworkError::Setup { error, .. } => panic!("Failed to start bot: {:?}", error), poise::FrameworkError::Command { error, ctx, .. } => { @@ -54,11 +58,11 @@ impl EventHandler for Handler { } async fn ready(&self, ctx: Context, ready: Ready) { - println!("{} v0.3 is connected!", ready.user.name); + println!("{} v3.3.1 is connected!", ready.user.name); #[cfg(feature="RELEASE")] { - use util::debug::hello; - hello(ctx.http.clone()).await; + use utils::debug::hello; + let _ = hello(ctx.http.clone()).await; } let sched = JobScheduler::new().await.unwrap(); @@ -82,10 +86,15 @@ async fn main() -> anyhow::Result<()> { dotenv().ok(); // create poise framework for registering commands - let options: poise::FrameworkOptions<(), Box> = poise::FrameworkOptions { + let options: poise::FrameworkOptions> = poise::FrameworkOptions { commands: vec![ commands::say(), - commands::hug() + commands::hug(), + // commands::player::play_local(), + commands::radio::radio(), + commands::general_player::play(), + commands::player_common::disconnect(), + commands::player_common::playing() ], prefix_options: poise::PrefixFrameworkOptions { prefix: Some("/".into()), @@ -107,17 +116,29 @@ async fn main() -> anyhow::Result<()> { let framework = poise::Framework::builder() .setup(move |ctx, _ready, framework| { Box::pin(async move { + #[cfg(feature="GUILD_COMMAND")] { + use poise::samples::register_in_guild; + use serenity::all::GuildId; + let debug_guild_id = env::var("DEBUG_GUILD_ID") .context("DEBUG_GUILD_ID not found in env")? .parse::().unwrap(); - - register_in_guild(ctx, &framework.options().commands, debug_guild_id).await?; + + register_in_guild(ctx, &framework.options().commands, GuildId::new(debug_guild_id)).await?; } + #[cfg(not(feature="GUILD_COMMAND"))] { + use poise::samples::register_globally; register_globally(ctx, &framework.options().commands).await?; } - Ok(()) + + + Ok(Data { + http_client: HttpClient::new(), + radio_browser: RadioBrowserAPI::new().await?, + playing_info: Mutex::new(HashMap::new()) + }) }) }) .options(options) @@ -130,11 +151,17 @@ async fn main() -> anyhow::Result<()> { let token = env::var(token_str).context("TOKEN not found in env")?; - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; + let intents = GatewayIntents::GUILDS + | GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::GUILD_VOICE_STATES + | GatewayIntents::GUILD_MESSAGE_REACTIONS + | GatewayIntents::GUILD_MESSAGE_TYPING; let mut client = Client::builder(&token, intents) .event_handler(Handler) .framework(framework) + .register_songbird() .await .context("Failed to build client")?; diff --git a/src/message_handler.rs b/src/message_handler.rs index 4aa263e..8033036 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -5,8 +5,8 @@ use serenity::model::channel::Message; use std::collections::HashMap; use std::sync::Arc; -use crate::util::debug::send_error; -use crate::util::utilities; +use crate::utils::debug::send_error; +use crate::utils::utilities; use crate::commands::moover::moove::{moove, moove_check}; diff --git a/src/other/notice.rs b/src/other/notice.rs index 69b39ed..9d75231 100644 --- a/src/other/notice.rs +++ b/src/other/notice.rs @@ -6,8 +6,8 @@ use serenity::{all::{GuildId, UserId}, builder::{CreateEmbed, CreateMessage}, cl use sqlx::{Connection, FromRow, SqliteConnection}; -use crate::util::debug::send_error; -use crate::util::utilities; +use crate::utils::debug::send_error; +use crate::utils::utilities; use std::{env, sync::Arc}; diff --git a/src/types.rs b/src/types.rs index b5ebced..00ef4f9 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,37 @@ -// pub struct Data {} +use std::collections::HashMap; + +use poise::CreateReply; +use radiobrowser::RadioBrowserAPI; +use reqwest::Client as HttpClient; +use serenity::{all::{futures::lock::Mutex, GuildId}, async_trait}; + +use crate::commands::voice_types::PlayingInfoType; + +// TODO: this should be probably expanded also to be used by multiple servers and commands +// radio and spotify commands always create new HttpClient +pub struct Data { + pub http_client: HttpClient, + pub radio_browser: RadioBrowserAPI, + pub playing_info: Mutex> +} pub type Error = Box; // replace () with Data if you ever need to store some additional data -pub type Context<'a> = poise::Context<'a, (), Error>; +pub type Context<'a> = poise::Context<'a, Data, Error>; + +/// Trait to extend `Context` with additional methods. +#[async_trait] +pub trait ContextExt { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()>; +} + +#[async_trait] +impl ContextExt for Context<'_> { + async fn reply_ephemeral(&self, content: &str) -> anyhow::Result<()> { + self.send(CreateReply::default() + .ephemeral(true) + .content(content) + ).await?; + Ok(()) + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs deleted file mode 100644 index a259bb4..0000000 --- a/src/util/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod debug; -pub mod utilities; -pub mod gifs; \ No newline at end of file diff --git a/src/util/utilities.rs b/src/util/utilities.rs deleted file mode 100644 index 8b5f470..0000000 --- a/src/util/utilities.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::sync::Arc; - -use serenity::{all::{ChannelId, CreateMessage, GuildId, Message}, http::Http}; - -use anyhow::Context; - -pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { - return http.get_guild(guild_id).await?.system_channel_id - .context(format!("System channel of guild: {} not found", guild_id.get())); -} - -pub async fn replace_msg(http: Arc, msg: Message, content: String) -> Result { - msg.delete(http.clone()).await?; - - return ChannelId::new(msg.channel_id.get()).send_message(http.clone(), CreateMessage::new().content(content)).await; -} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..fabda60 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,5 @@ +pub use utilities::*; + +pub mod debug; +pub mod utilities; +pub mod gifs; diff --git a/src/util/debug.rs b/src/utils/debug.rs similarity index 100% rename from src/util/debug.rs rename to src/utils/debug.rs diff --git a/src/utils/embeds.rs b/src/utils/embeds.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/util/gifs.rs b/src/utils/gifs.rs similarity index 100% rename from src/util/gifs.rs rename to src/utils/gifs.rs diff --git a/src/utils/utilities.rs b/src/utils/utilities.rs new file mode 100644 index 0000000..f24e427 --- /dev/null +++ b/src/utils/utilities.rs @@ -0,0 +1,73 @@ +use std::{fs, hash::{DefaultHasher, Hash, Hasher}, io, path::Path, sync::Arc, vec}; + +use serenity::{all::{ChannelId, ChannelType, CreateMessage, GuildId, GuildRef, Message}, http::Http}; + +use poise::CreateReply; +use serenity::async_trait; + +use crate::types::Context; + + +pub async fn get_system_channel(guild_id: GuildId, http: &Http) -> anyhow::Result { + use anyhow::Context; + return http.get_guild(guild_id).await?.system_channel_id + .context(format!("System channel of guild: {} not found", guild_id.get())); +} + +pub async fn replace_msg(http: Arc, msg: Message, content: String) -> Result { + msg.delete(http.clone()).await?; + + return ChannelId::new(msg.channel_id.get()).send_message(http.clone(), CreateMessage::new().content(content)).await; +} + +pub fn get_vc_names(guild: GuildRef) -> Vec { + + let mut result: Vec = [].to_vec(); + for (_, channel) in &guild.channels { + if channel.kind == ChannelType::Voice { + result.push(channel.name.clone()); + } + } + + result +} + +pub fn get_channel_by_name(guild: GuildRef, name: String) -> Option { + let mut result = None; + for (_, channel) in &guild.channels { + if channel.name.contains(&name) { + result = Some(channel.id); + break; + } + } + + result +} + +pub fn get_local_songs(partial: &str) -> io::Result> { + let mut result: Vec = vec![]; + let path = Path::new("/home/emil/Music"); + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + continue; + } + + let name = entry.file_name(); + if name.to_str().is_some_and(|name| !name.to_lowercase().contains(&partial.to_lowercase())) { + continue; + } + + result.push(name.to_str().unwrap().into()); + } + + Ok(result) +} + +pub fn hash_to_u32(from: &T) -> u32 { + let mut hasher = DefaultHasher::new(); + from.hash(&mut hasher); + let hash_val = hasher.finish(); + (hash_val & 0xFFFFFF) as u32 +} diff --git a/tenor-v2 b/tenor-v2 index fd8c8bd..66cf5cc 160000 --- a/tenor-v2 +++ b/tenor-v2 @@ -1 +1 @@ -Subproject commit fd8c8bde1be41116fe398776c45625a19ec10281 +Subproject commit 66cf5cc388af947846caf5b7118257df01d60725