diff --git a/Cargo.lock b/Cargo.lock index 1201760..0334a76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1084,22 +1093,23 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "fren" -version = "2.6.1" +version = "2.7.0" dependencies = [ "axum 0.8.1", "base64 0.22.1", "chrono", "chrono-tz 0.10.1", "config", + "convert_case 0.10.0", "cta-api", "emojis", "j_db", @@ -1127,6 +1137,7 @@ dependencies = [ "tonic", "tracing-core", "tracing-subscriber", + "url", ] [[package]] @@ -1830,9 +1841,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -2437,9 +2448,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" @@ -4928,9 +4939,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", diff --git a/Cargo.toml b/Cargo.toml index b158974..556a5a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fren" -version = "2.6.1" +version = "2.7.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -35,6 +35,8 @@ thiserror = "2.0.12" tracing-core = "0.1.33" strum = { version = "0.27.2", features = ["derive"] } thousands = "0.2.0" +url = { version = "2.5.7", features = ["serde"] } +convert_case = "0.10.0" [dependencies.tokio] version = "1.35.1" diff --git a/src/config.rs b/src/config.rs index f842050..5959bc9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,6 +48,8 @@ pub struct BotConfig { pub raas_server: String, pub cta_key: String, pub omdb_key: String, + pub music_streaming_site_urls: Vec, + pub music_streaming_sites: Vec, pub picox: PicOxConfig, } diff --git a/src/discord/improvements.rs b/src/discord/improvements.rs index fe18f5e..9334e80 100644 --- a/src/discord/improvements.rs +++ b/src/discord/improvements.rs @@ -75,11 +75,7 @@ pub async fn buy_improvement( return Ok(()); } - User::try_take_funds( - &ctx.data().db, - ctx.author().id, - amount, - )?; + User::try_take_funds(&ctx.data().db, ctx.author().id, amount)?; let (has_improved, _) = Improvements::contribute_to_improvement(&ctx.data().db, improvement_type, amount as u64)?; diff --git a/src/discord/mod.rs b/src/discord/mod.rs index 1d2c390..8cde7c3 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -1,4 +1,5 @@ use rand::seq::IteratorRandom; +use std::str::FromStr; mod admin; mod album; mod birthday; @@ -24,14 +25,19 @@ use crate::error::Error; use crate::event_listener::{Listener, TriggerEvent, TriggerType}; use crate::models::social_credit::SocialCreditPhrase; use crate::models::task::Task; +use crate::music::songlink::get_song_urls; use crate::user::{User, UserRole}; use log::{debug, error, info}; -use poise::serenity_prelude::{GuildId, Http, Message, MessageBuilder, ReactionType, RoleId}; +use poise::serenity_prelude::{ + CreateMessage, GuildId, Http, Message, MessageBuilder, ReactionType, RoleId, +}; use poise::{FrameworkOptions, find_command, serenity_prelude as serenity}; use rand::{Rng, rng}; +use regex::Regex; use songbird::SerenityInit; use std::sync::Arc; use std::time::Duration; +use url::Url; pub type Context<'a> = poise::Context<'a, Arc, Error>; @@ -75,6 +81,42 @@ async fn event_handler( Ok(()) } +async fn check_message_for_music_link( + ctx: &serenity::Context, + data: &Arc, + new_message: &Message, +) -> Result<(), Error> { + let re = Regex::new(r"(?:https?://.)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_+.~#?&/=]*").unwrap(); + + if let Some(url_match) = re.find(&new_message.content) { + let url = url_match.as_str(); + if data + .cfg + .music_streaming_site_urls + .iter() + .any(|site| url.contains(site)) + { + match get_song_urls(Url::from_str(url).unwrap()).await { + Ok(resp) => { + new_message + .channel_id + .send_message( + &ctx.http, + CreateMessage::default() + .embed(resp.to_discord_message(&data.cfg.music_streaming_sites)), + ) + .await?; + } + Err(err) => { + error!("Failed to handle music link: {}", err); + } + } + } + } + + Ok(()) +} + async fn handle_message( ctx: &serenity::Context, data: &Arc, @@ -151,6 +193,8 @@ async fn handle_message( } } + check_message_for_music_link(ctx, data, new_message).await?; + Ok(()) } diff --git a/src/main.rs b/src/main.rs index 7cf14ed..f5d7b52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod image_manipulation; mod inventory; mod migrations; mod models; +mod music; mod user; use crate::config::{Args, BotConfig, GlobalData}; diff --git a/src/music/mod.rs b/src/music/mod.rs new file mode 100644 index 0000000..115720a --- /dev/null +++ b/src/music/mod.rs @@ -0,0 +1 @@ +pub mod songlink; diff --git a/src/music/songlink.rs b/src/music/songlink.rs new file mode 100644 index 0000000..fbe17df --- /dev/null +++ b/src/music/songlink.rs @@ -0,0 +1,93 @@ +use convert_case::{Case, Casing}; +use poise::serenity_prelude::{Color, CreateEmbed, MessageBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; + +const API_URL: &str = "https://api.song.link/v1-alpha.1/links"; + +#[derive(Serialize, Debug)] +pub struct SonglinkRequest { + url: Url, + #[serde(rename = "songIfSingle")] + song_if_single: bool, +} + +#[derive(Deserialize, Hash, Debug)] +pub struct Platform { + url: String, + #[serde(rename = "nativeAppUriMobile", default)] + native_app_uri_mobile: Option, + #[serde(rename = "nativeAppUriDesktop", default)] + native_app_uri_desktop: Option, +} + +#[derive(Deserialize, Hash, Debug)] +pub struct Entity { + title: String, + #[serde(rename = "artistName", default)] + artist_name: String, + #[serde(rename = "thumbnailUrl", default)] + thumbnail_url: String, +} + +#[allow(dead_code)] +#[derive(Deserialize, Debug)] +pub struct SonglinkResponse { + #[serde(rename = "entityUniqueId")] + pub entity_unique_id: String, + #[serde(rename = "userCountry")] + pub user_country: String, + #[serde(rename = "linksByPlatform")] + pub links_by_platform: HashMap, + #[serde(rename = "entitiesByUniqueId")] + pub entities_by_unique_id: HashMap, +} + +impl SonglinkResponse { + pub fn to_discord_message(&self, platforms: &[String]) -> CreateEmbed { + let embed = if let Some((_id, entity)) = self.entities_by_unique_id.iter().next() { + CreateEmbed::new() + .title(entity.title.clone()) + .field("Artist:", entity.artist_name.clone(), true) + .thumbnail(entity.thumbnail_url.clone()) + .color(Color::from_rgb(0xf2, 0x55, 0x43)) + } else { + CreateEmbed::new() + .title("Alternative Links") + .color(Color::from_rgb(0xf2, 0x55, 0x43)) + }; + + let mut msg_builder = MessageBuilder::new(); + for (platform, link) in &self.links_by_platform { + let platform_search_name = platform.to_lowercase(); + + if platforms.contains(&platform_search_name) { + msg_builder.push_line(format!( + "* [{}]({})", + platform.to_case(Case::Title), + link.url + )); + } + } + + embed.field("Available At", msg_builder.build(), false) + } +} + +pub async fn get_song_urls(url: Url) -> Result { + let request = SonglinkRequest { + url, + song_if_single: true, + }; + + let client = reqwest::Client::new(); + + let resp = client + .execute(client.get(API_URL).query(&request).build()?) + .await?; + + let resp: SonglinkResponse = resp.json().await?; + + Ok(resp) +}