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