diff --git a/Cargo.lock b/Cargo.lock index a457f86..82533b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -542,6 +542,7 @@ dependencies = [ "bitflags 2.4.1", "const_format", "dashmap", + "futures-util", "gettext", "indexmap", "itertools 0.12.0", diff --git a/Cargo.toml b/Cargo.toml index cf0d979..9c8f5f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ arrayvec = "0.7.4" bitflags = "2.4.1" paste = "1.0.14" typesize = "0.1.2" +futures-util = { version = "0.3.29", default-features = false } [dependencies.symphonia] features = ["mp3", "ogg", "wav", "pcm"] diff --git a/src/commands/main.rs b/src/commands/main.rs index 5cb6381..f4bd42b 100644 --- a/src/commands/main.rs +++ b/src/commands/main.rs @@ -14,20 +14,14 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{borrow::Cow, num::NonZeroU16}; - use songbird::error::JoinError; -use sqlx::Row; -use poise::{ - serenity_prelude::{self as serenity, builder::*}, - CreateReply, -}; +use poise::serenity_prelude::{self as serenity, builder::*}; use crate::{ funcs::random_footer, require, require_guild, - structs::{Command, CommandResult, Context, JoinVCToken, Result, TTSMode}, + structs::{Command, CommandResult, Context, JoinVCToken, Result}, traits::{PoiseContextExt, SongbirdManagerExt}, }; @@ -252,97 +246,6 @@ pub async fn clear(ctx: Context<'_>) -> CommandResult { Ok(()) } -/// Activates a server for TTS Bot Premium! -#[poise::command( - category = "Main Commands", - guild_only, - prefix_command, - slash_command, - aliases("activate"), - required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" -)] -pub async fn premium_activate(ctx: Context<'_>) -> CommandResult { - let guild_id = ctx.guild_id().unwrap(); - let data = ctx.data(); - - if data.premium_check(Some(guild_id)).await?.is_none() { - ctx.say(ctx.gettext("Hey, this server is already premium!")) - .await?; - return Ok(()); - } - - let author = ctx.author(); - let author_id = ctx.author().id.get() as i64; - - let linked_guilds: i64 = sqlx::query("SELECT count(*) FROM guilds WHERE premium_user = $1") - .bind(author_id) - .fetch_one(&data.pool) - .await? - .get("count"); - - let error_msg = match data.fetch_patreon_info(author.id).await? { - Some(tier) => { - if linked_guilds as u8 >= tier.entitled_servers { - Some(Cow::Owned(ctx - .gettext("Hey, you already have {server_count} servers linked, you are only subscribed to the {entitled_servers} tier!") - .replace("{entitled_servers}", &tier.entitled_servers.to_string()) - .replace("{server_count}", &linked_guilds.to_string()) - )) - } else { - None - } - } - None => Some(Cow::Borrowed( - ctx.gettext("Hey, I don't think you are subscribed on Patreon!"), - )), - }; - - if let Some(error_msg) = error_msg { - ctx.send(CreateReply::default().embed(CreateEmbed::default() - .title("TTS Bot Premium") - .description(error_msg) - .thumbnail(&data.premium_avatar_url) - .colour(crate::constants::PREMIUM_NEUTRAL_COLOUR) - .footer(CreateEmbedFooter::new({ - let line1 = ctx.gettext("If you have just subscribed, please wait for up to an hour for the member list to update!\n"); - let line2 = ctx.gettext("If this is incorrect, and you have waited an hour, please contact GnomedDev."); - - let mut concat = String::with_capacity(line1.len() + line2.len()); - concat.push_str(line1); - concat.push_str(line2); - concat - })) - )).await?; - - return Ok(()); - } - - data.userinfo_db.create_row(author_id).await?; - data.guilds_db - .set_one(guild_id.into(), "premium_user", &author_id) - .await?; - data.guilds_db - .set_one(guild_id.into(), "voice_mode", &TTSMode::gCloud) - .await?; - - ctx.say(ctx.gettext("Done! This server is now premium!")) - .await?; - - let guild = ctx.cache().guild(guild_id); - let guild_name = guild.as_ref().map_or("", |g| g.name.as_str()); - - tracing::info!( - "{}#{} | {} linked premium to {} | {}, they had {} linked servers", - author.name, - author.discriminator.map_or(0, NonZeroU16::get), - author.id, - guild_name, - guild_id, - linked_guilds - ); - Ok(()) -} - -pub fn commands() -> [Command; 4] { - [join(), leave(), clear(), premium_activate()] +pub fn commands() -> [Command; 3] { + [join(), leave(), clear()] } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index fb9b9b3..cf49fb0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -20,6 +20,7 @@ mod help; mod main; mod other; mod owner; +mod premium; mod settings; pub fn commands() -> Vec { @@ -27,6 +28,7 @@ pub fn commands() -> Vec { .into_iter() .chain(other::commands()) .chain(settings::commands()) + .chain(premium::commands()) .chain(owner::commands()) .chain(help::commands()) .collect() diff --git a/src/commands/owner.rs b/src/commands/owner.rs index 8ac36e9..a4e1574 100644 --- a/src/commands/owner.rs +++ b/src/commands/owner.rs @@ -17,7 +17,6 @@ use std::{ borrow::Cow, collections::{HashMap, HashSet}, - num::NonZeroU16, sync::atomic::Ordering::SeqCst, }; @@ -81,36 +80,11 @@ pub async fn close(ctx: Context<'_>) -> CommandResult { Ok(()) } -#[poise::command(prefix_command, owners_only, hide_in_help)] -pub async fn add_premium( - ctx: Context<'_>, - guild: serenity::Guild, - user: serenity::User, -) -> CommandResult { - let data = ctx.data(); - let user_id = user.id.into(); - - data.userinfo_db.create_row(user_id).await?; - data.guilds_db - .set_one(guild.id.into(), "premium_user", &user_id) - .await?; - - ctx.say(format!( - "Linked <@{}> ({}#{} | {}) to {}", - user.id, - user.name, - user.discriminator.map_or(0, NonZeroU16::get), - user.id, - guild.name - )) - .await?; - Ok(()) -} - #[poise::command( prefix_command, owners_only, hide_in_help, + aliases("invalidate_cache", "delete_cache"), subcommands("guild", "user", "guild_voice", "user_voice") )] pub async fn remove_cache(ctx: Context<'_>) -> CommandResult { @@ -429,13 +403,12 @@ pub async fn cache_info(ctx: Context<'_>, kind: Option) -> CommandResult Ok(()) } -pub fn commands() -> [Command; 9] { +pub fn commands() -> [Command; 8] { [ dm(), close(), debug(), register(), - add_premium(), remove_cache(), refresh_ofs(), purge_guilds(), diff --git a/src/commands/premium.rs b/src/commands/premium.rs new file mode 100644 index 0000000..67bba11 --- /dev/null +++ b/src/commands/premium.rs @@ -0,0 +1,208 @@ +use std::{borrow::Cow, fmt::Write as _}; + +use futures_util::{stream::BoxStream, StreamExt as _}; + +use poise::{ + serenity_prelude::{self as serenity, builder::*}, + CreateReply, +}; + +use crate::{ + constants::PREMIUM_NEUTRAL_COLOUR, + funcs::remove_premium, + structs::{Command, CommandResult, Context, Result, TTSMode}, + traits::PoiseContextExt, +}; + +#[derive(sqlx::FromRow)] +struct GuildIdRow { + guild_id: i64, +} + +fn get_premium_guilds<'a>( + conn: impl sqlx::PgExecutor<'a> + 'a, + premium_user: serenity::UserId, +) -> BoxStream<'a, Result> { + sqlx::query_as("SELECT guild_id FROM guilds WHERE premium_user = $1") + .bind(premium_user.get() as i64) + .fetch(conn) +} + +async fn get_premium_guild_count<'a>( + conn: impl sqlx::PgExecutor<'a> + 'a, + premium_user: serenity::UserId, +) -> Result { + let guilds = get_premium_guilds(conn, premium_user); + Ok(guilds.count().await as i64) +} + +/// Activates a server for TTS Bot Premium! +#[poise::command( + category = "Premium Management", + guild_only, + prefix_command, + slash_command, + aliases("activate"), + required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" +)] +pub async fn premium_activate(ctx: Context<'_>) -> CommandResult { + let guild_id = ctx.guild_id().unwrap(); + let data = ctx.data(); + + if data.premium_check(Some(guild_id)).await?.is_none() { + ctx.say(ctx.gettext("Hey, this server is already premium!")) + .await?; + return Ok(()); + } + + let author = ctx.author(); + let linked_guilds = get_premium_guild_count(&data.pool, author.id).await?; + let error_msg = match data.fetch_patreon_info(author.id).await? { + Some(tier) => { + if linked_guilds as u8 >= tier.entitled_servers { + Some(Cow::Owned(ctx + .gettext("Hey, you already have {server_count} servers linked, you are only subscribed to the {entitled_servers} tier!") + .replace("{entitled_servers}", &tier.entitled_servers.to_string()) + .replace("{server_count}", &linked_guilds.to_string()) + )) + } else { + None + } + } + None => Some(Cow::Borrowed( + ctx.gettext("Hey, I don't think you are subscribed on Patreon!"), + )), + }; + + if let Some(error_msg) = error_msg { + ctx.send(CreateReply::default().embed(CreateEmbed::default() + .title("TTS Bot Premium") + .description(error_msg) + .thumbnail(&data.premium_avatar_url) + .colour(crate::constants::PREMIUM_NEUTRAL_COLOUR) + .footer(CreateEmbedFooter::new({ + let line1 = ctx.gettext("If you have just subscribed, please wait for up to an hour for the member list to update!\n"); + let line2 = ctx.gettext("If this is incorrect, and you have waited an hour, please contact GnomedDev."); + + let mut concat = String::with_capacity(line1.len() + line2.len()); + concat.push_str(line1); + concat.push_str(line2); + concat + })) + )).await?; + + return Ok(()); + } + + let author_id = author.id.get() as i64; + data.userinfo_db.create_row(author_id).await?; + data.guilds_db + .set_one(guild_id.into(), "premium_user", &author_id) + .await?; + data.guilds_db + .set_one(guild_id.into(), "voice_mode", &TTSMode::gCloud) + .await?; + + ctx.say(ctx.gettext("Done! This server is now premium!")) + .await?; + + let guild = ctx.cache().guild(guild_id); + let guild_name = guild.as_ref().map_or("", |g| g.name.as_str()); + + tracing::info!( + "{}#{} | {} linked premium to {} | {}, they had {} linked servers", + author.name, + author.discriminator.map_or(0, std::num::NonZeroU16::get), + author.id, + guild_name, + guild_id, + linked_guilds + ); + Ok(()) +} + +/// Lists all servers you activated for TTS Bot Premium +#[poise::command( + category = "Premium Management", + prefix_command, + slash_command, + required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" +)] +pub async fn list_premium(ctx: Context<'_>) -> CommandResult { + let data = ctx.data(); + let Some(premium_info) = ctx.data().fetch_patreon_info(ctx.author().id).await? else { + ctx.say(ctx.gettext("I cannot confirm you are subscribed on patreon, so you don't have any premium servers!")).await?; + return Ok(()); + }; + + let mut premium_guilds = 0; + let mut embed_desc = String::new(); + let mut guilds = get_premium_guilds(&data.pool, ctx.author().id); + while let Some(guild_row) = guilds.next().await { + premium_guilds += 1; + let guild_id = serenity::GuildId::new(guild_row?.guild_id as u64); + if let Some(guild_ref) = ctx.cache().guild(guild_id) { + writeln!(embed_desc, "- (`{guild_id}`) {}", guild_ref.name)?; + } else { + writeln!(embed_desc, "- (`{guild_id}`) ****")?; + } + } + + let author = ctx.author(); + let remaining_guilds = premium_info.entitled_servers - premium_guilds; + if embed_desc.is_empty() { + embed_desc = String::from("None... set some servers with `/premium_activate`!"); + } + + let embed = CreateEmbed::new() + .title("The premium servers you have activated:") + .description(embed_desc) + .colour(PREMIUM_NEUTRAL_COLOUR) + .author(CreateEmbedAuthor::new(author.name.clone()).icon_url(author.face())) + .footer(CreateEmbedFooter::new(format!( + "You have {remaining_guilds} server(s) remaining for premium activation" + ))); + + ctx.send(CreateReply::default().embed(embed)).await?; + Ok(()) +} + +/// Deactivates a server from TTS Bot Premium. +#[poise::command( + category = "Premium Management", + prefix_command, + slash_command, + guild_only, + aliases("premium_remove", "premium_delete"), + required_bot_permissions = "SEND_MESSAGES | EMBED_LINKS" +)] +pub async fn premium_deactivate(ctx: Context<'_>) -> CommandResult { + let data = ctx.data(); + let author = ctx.author(); + let guild_id = ctx.guild_id().unwrap(); + let guild_row = data.guilds_db.get(guild_id.get() as i64).await?; + + let Some(premium_user) = guild_row.premium_user else { + let msg = ctx.gettext("This server isn't activated for premium, so I can't deactivate it!"); + ctx.send_ephemeral(msg).await?; + return Ok(()); + }; + + if premium_user != author.id { + let msg = ctx.gettext( + "You are not setup as the premium user for this server, so cannot deactivate it!", + ); + ctx.send_ephemeral(msg).await?; + return Ok(()); + } + + remove_premium(data, guild_id).await?; + + let msg = ctx.gettext("Deactivated premium from this server."); + ctx.say(msg).await?; + Ok(()) +} + +pub fn commands() -> [Command; 3] { + [premium_activate(), list_premium(), premium_deactivate()] +} diff --git a/src/database.rs b/src/database.rs index 0736de3..06fd987 100644 --- a/src/database.rs +++ b/src/database.rs @@ -147,7 +147,7 @@ where .execute(&self.pool) .await?; - self.cache.remove(&identifier); + self.invalidate_cache(&identifier); Ok(()) } @@ -157,7 +157,7 @@ where .execute(&self.pool) .await?; - self.cache.remove(&identifier); + self.invalidate_cache(&identifier); Ok(()) } diff --git a/src/events/member.rs b/src/events/member.rs index e5c0141..885fca4 100644 --- a/src/events/member.rs +++ b/src/events/member.rs @@ -3,7 +3,7 @@ use reqwest::StatusCode; use crate::{ constants::PREMIUM_NEUTRAL_COLOUR, - funcs::{confirm_dialog_components, confirm_dialog_wait}, + funcs::{confirm_dialog_components, confirm_dialog_wait, remove_premium}, Data, Result, }; @@ -43,12 +43,6 @@ pub async fn guild_member_addition( Ok(()) } -async fn remove_premium(data: &Data, guild_id: serenity::GuildId) -> Result<()> { - data.guilds_db - .set_one(guild_id.into(), "premium_user", None::) - .await -} - fn create_premium_notice() -> serenity::CreateMessage { let embed = serenity::CreateEmbed::new() .colour(PREMIUM_NEUTRAL_COLOUR) diff --git a/src/funcs.rs b/src/funcs.rs index aa0475c..359aadf 100644 --- a/src/funcs.rs +++ b/src/funcs.rs @@ -27,7 +27,7 @@ use crate::{ opt_ext::{OptionGettext, OptionTryUnwrap}, require, structs::{ - Context, GoogleGender, GoogleVoice, LastToXsaidTracker, RegexCache, Result, TTSMode, + Context, Data, GoogleGender, GoogleVoice, LastToXsaidTracker, RegexCache, Result, TTSMode, TTSServiceError, }, }; @@ -36,6 +36,15 @@ pub async fn decode_resp(resp: reqwest::Response json::from_slice(&resp.bytes().await?).map_err(Into::into) } +pub async fn remove_premium(data: &Data, guild_id: serenity::GuildId) -> Result<()> { + data.guilds_db + .set_one(guild_id.into(), "premium_user", None::) + .await?; + data.guilds_db + .set_one(guild_id.into(), "voice_mode", None::) + .await +} + pub async fn dm_generic( ctx: &serenity::Context, author: &serenity::User, diff --git a/src/structs.rs b/src/structs.rs index 87e7c39..ea8a471 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -194,15 +194,8 @@ impl Data { if let Some(mut url) = self.config.patreon_service.clone() { url.set_path(&format!("/members/{user_id}")); - let resp = self - .reqwest - .get(url) - .send() - .await? - .error_for_status()? - .bytes() - .await?; - + let req = self.reqwest.get(url); + let resp = req.send().await?.error_for_status()?.bytes().await?; json::from_slice(&resp).map_err(Into::into) } else { // Return fake PatreonInfo if `patreon_service` has not been set to simplify self-hosting. diff --git a/src/traits.rs b/src/traits.rs index 104fc94..c95efba 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -14,6 +14,7 @@ pub trait PoiseContextExt { fn gettext<'a>(&'a self, translate: &'a str) -> &'a str; fn current_catalog(&self) -> Option<&gettext::Catalog>; + async fn send_ephemeral(&self, message: impl Into) -> Result>; async fn send_error(&self, error_message: String) -> Result>>; async fn neutral_colour(&self) -> u32; @@ -84,6 +85,12 @@ impl PoiseContextExt for Context<'_> { Ok(guild.user_permissions_in(channel, &member)) } + async fn send_ephemeral(&self, message: impl Into) -> Result> { + let reply = poise::CreateReply::default().content(message); + let handle = self.send(reply).await?; + Ok(handle) + } + async fn send_error(&self, error_message: String) -> Result>> { let author = self.author(); let serenity_ctx = self.serenity_context();