diff --git a/migrations/20250924203400_patreon_link.sql b/migrations/20250924203400_patreon_link.sql new file mode 100644 index 0000000..70b0353 --- /dev/null +++ b/migrations/20250924203400_patreon_link.sql @@ -0,0 +1,11 @@ +CREATE TABLE patreon_link +( + user_id BIGINT UNSIGNED NOT NULL, + guild_id BIGINT UNSIGNED NOT NULL, + linked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (user_id), + INDEX `idx_user_id` (user_id), + INDEX `idx_guild_id` (guild_id), + INDEX `idx_linked_at` (linked_at) +); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0c24c58..323610f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -12,8 +12,6 @@ pub mod dashboard; #[cfg(not(test))] pub mod delete; #[cfg(not(test))] -pub mod donate; -#[cfg(not(test))] pub mod help; #[cfg(not(test))] pub mod info; @@ -26,6 +24,8 @@ pub mod nudge; #[cfg(not(test))] pub mod offset; #[cfg(not(test))] +pub mod patreon; +#[cfg(not(test))] pub mod pause; #[cfg(not(test))] pub mod remind; diff --git a/src/commands/donate.rs b/src/commands/patreon/info.rs similarity index 71% rename from src/commands/donate.rs rename to src/commands/patreon/info.rs index 3f49261..29b6a06 100644 --- a/src/commands/donate.rs +++ b/src/commands/patreon/info.rs @@ -15,8 +15,8 @@ impl Recordable for Options { let footer = footer(ctx); ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") - .description("Thinking of adding a monthly contribution? -Click below for my Patreon and official bot server :) + .description("Thinking of subscribing? +Click below for my Patreon and official bot server **https://www.patreon.com/jellywx/** **https://discord.jellywx.com/** @@ -26,7 +26,7 @@ With your new rank, you'll be able to: • Set repeating reminders with `/remind` or the dashboard • Use unlimited uploads on SoundFX -(Also, members of servers you __own__ will be able to set repeating reminders via commands) +Members of servers you __own__ will be able to set repeating reminders via commands. You can also choose to share your membership with one other server. Just $2 USD/month! @@ -41,8 +41,8 @@ Just $2 USD/month! } } -/// Details on supporting the bot and Patreon benefits -#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")] -pub async fn command(ctx: Context<'_>) -> Result<(), Error> { +/// Show Patreon information +#[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")] +pub async fn info(ctx: Context<'_>) -> Result<(), Error> { (Options {}).run(ctx).await } diff --git a/src/commands/patreon/link.rs b/src/commands/patreon/link.rs new file mode 100644 index 0000000..4410d77 --- /dev/null +++ b/src/commands/patreon/link.rs @@ -0,0 +1,73 @@ +use poise::CreateReply; +use serde::{Deserialize, Serialize}; + +use crate::{ + utils::{check_user_subscription, Extract, Recordable}, + Context, Error, +}; + +#[derive(Serialize, Deserialize, Extract)] +pub struct Options; + +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?; + let user_id = ctx.author().id; + + // Check if user has Patreon subscription + if !check_user_subscription(ctx, user_id).await { + ctx.send(CreateReply::default() + .content("❌ You must be a Patreon subscriber to use this command. Use `/patreon info` for more information.") + .ephemeral(true) + ).await?; + return Ok(()); + } + + let existing_link = sqlx::query!( + "SELECT linked_at FROM patreon_link WHERE user_id = ? AND linked_at > NOW() - INTERVAL 4 WEEK", + user_id.get() + ) + .fetch_optional(&ctx.data().database) + .await?; + + if existing_link.is_some() { + ctx.send( + CreateReply::default() + .content("❌ You can only link once every 4 weeks. Please try again later.") + .ephemeral(true), + ) + .await?; + return Ok(()); + } + + // Insert or update the patreon_link entry + sqlx::query!( + "INSERT INTO patreon_link (user_id, guild_id, linked_at) VALUES (?, ?, NOW()) + ON DUPLICATE KEY UPDATE user_id = user_id", + user_id.get(), + guild_id.get() + ) + .execute(&ctx.data().database) + .await?; + + ctx.send( + CreateReply::default() + .content("✅ Successfully linked your Patreon subscription to this server!") + .ephemeral(true), + ) + .await?; + + Ok(()) + } +} + +/// Link your Patreon subscription to this server. This command can be run once every four weeks +#[poise::command( + slash_command, + rename = "link", + identifying_name = "patreon_link", + guild_only = true +)] +pub async fn link(ctx: Context<'_>) -> Result<(), Error> { + (Options {}).run(ctx).await +} diff --git a/src/commands/patreon/mod.rs b/src/commands/patreon/mod.rs new file mode 100644 index 0000000..54d31db --- /dev/null +++ b/src/commands/patreon/mod.rs @@ -0,0 +1,11 @@ +pub mod info; +pub mod link; +pub mod unlink; + +use crate::{Context, Error}; + +/// Manage Patreon subscription features +#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")] +pub async fn command(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} diff --git a/src/commands/patreon/unlink.rs b/src/commands/patreon/unlink.rs new file mode 100644 index 0000000..1a0cbcf --- /dev/null +++ b/src/commands/patreon/unlink.rs @@ -0,0 +1,55 @@ +use poise::CreateReply; +use serde::{Deserialize, Serialize}; + +use crate::{ + utils::{Extract, Recordable}, + Context, Error, +}; + +#[derive(Serialize, Deserialize, Extract)] +pub struct Options; + +impl Recordable for Options { + async fn run(self, ctx: Context<'_>) -> Result<(), Error> { + let guild_id = ctx.guild_id().ok_or("This command must be used in a server")?; + let user_id = ctx.author().id; + + // Remove the patreon_link entry + let result = sqlx::query!( + "DELETE FROM patreon_link WHERE user_id = ? AND guild_id = ?", + user_id.get(), + guild_id.get() + ) + .execute(&ctx.data().database) + .await?; + + if result.rows_affected() > 0 { + ctx.send( + CreateReply::default() + .content("✅ Successfully unlinked your Patreon subscription from this server!") + .ephemeral(true), + ) + .await?; + } else { + ctx.send( + CreateReply::default() + .content("❌ No existing Patreon link found for this server.") + .ephemeral(true), + ) + .await?; + } + + Ok(()) + } +} + +/// Unlink your Patreon subscription from this server +#[poise::command( + slash_command, + rename = "unlink", + identifying_name = "patreon_unlink", + guild_only = true +)] +pub async fn unlink(ctx: Context<'_>) -> Result<(), Error> { + (Options {}).run(ctx).await +} diff --git a/src/main.rs b/src/main.rs index 9bc776f..48ae1c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,8 +48,8 @@ use crate::test::TestContext; use crate::{ commands::{ allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard, - delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer, - timezone, todo, webhook, + delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings, + timer, timezone, todo, webhook, }, consts::THEME_COLOR, event_handlers::listener, @@ -165,7 +165,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box> { help::command(), info::command(), clock::command(), - donate::command(), + poise::Command { + subcommands: vec![ + patreon::link::link(), + patreon::unlink::unlink(), + patreon::info::info(), + ], + ..patreon::command() + }, clock_context_menu(), dashboard::command(), timezone::command(), diff --git a/src/models/command_macro.rs b/src/models/command_macro.rs index 90fdf1e..a6c2316 100644 --- a/src/models/command_macro.rs +++ b/src/models/command_macro.rs @@ -43,7 +43,7 @@ pub enum RecordedCommand { #[serde(rename = "delete")] Delete(crate::commands::delete::Options), #[serde(rename = "donate")] - Donate(crate::commands::donate::Options), + Donate(crate::commands::patreon::info::Options), #[serde(rename = "help")] Help(crate::commands::help::Options), #[serde(rename = "info")] @@ -111,7 +111,7 @@ impl RecordedCommand { "clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))), "dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))), "delete" => Some(Self::Delete(crate::commands::delete::Options::extract(ctx))), - "donate" => Some(Self::Donate(crate::commands::donate::Options::extract(ctx))), + "donate" => Some(Self::Donate(crate::commands::patreon::info::Options::extract(ctx))), "help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))), "info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))), "look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))), diff --git a/src/models/reminder/mod.rs b/src/models/reminder/mod.rs index 2b0157c..8d7a6a6 100644 --- a/src/models/reminder/mod.rs +++ b/src/models/reminder/mod.rs @@ -17,7 +17,7 @@ use crate::{ CtxData, }, time_parser::{cron_next_timestamp, natural_parser}, - utils::{check_guild_subscription, check_subscription}, + utils::check_subscription, Context, Database, Error, }; use chrono::{DateTime, NaiveDateTime, Utc}; @@ -532,10 +532,7 @@ pub async fn create_reminder( }; let (processed_interval, processed_expires) = if let Some(repeat) = &interval { - if check_subscription(&ctx, ctx.author().id).await - || (ctx.guild_id().is_some() - && check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await) - { + if check_subscription(&ctx, ctx.author().id, ctx.guild_id()).await { ( parse_duration(repeat) .or_else(|_| parse_duration(&format!("1 {}", repeat))) diff --git a/src/test/mod.rs b/src/test/mod.rs index 548aae6..cb9854f 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -3,6 +3,8 @@ use poise::{ CreateReply, }; use serde_json::Value; +use serenity::all::Http; +use serenity::http::CacheHttp; use tokio::sync::Mutex; use crate::{Data, Error}; @@ -19,6 +21,12 @@ pub(crate) struct TestContext<'a> { pub(crate) shard_id: usize, } +impl CacheHttp for TestContext<'_> { + fn http(&self) -> &Http { + todo!() + } +} + pub(crate) struct MockUser { pub(crate) id: UserId, } diff --git a/src/utils.rs b/src/utils.rs index faf2f8b..8ee1d23 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,7 +12,58 @@ use crate::{ ApplicationContext, Context, Error, }; -pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { +/// Check if this user/guild combination should be considered subscribed. +/// If the guild has a patreon linked, check the user involved in the link. +/// Otherwise, check the user and the guild's owner +pub async fn check_subscription( + ctx: &Context<'_>, + user_id: UserId, + guild_id: Option, +) -> bool { + if let Some(subscription_guild) = *CNC_GUILD { + let user_subscribed = check_user_subscription(ctx, user_id).await; + + let owner_subscribed = match guild_id { + Some(guild_id) => { + if let Some(owner) = ctx.cache().unwrap().guild(guild_id).map(|g| g.owner_id) { + check_user_subscription(ctx, owner).await + } else { + false + } + } + + None => false, + }; + + let link_subscribed = match guild_id { + Some(guild_id) => { + if let Ok(row) = sqlx::query!( + "SELECT user_id FROM patreon_link WHERE user_id = ?", + guild_id.get() + ) + .fetch_one(&ctx.data().database) + .await + { + check_user_subscription(ctx, row.user_id).await + } else { + false + } + } + + None => false, + }; + + user_subscribed || owner_subscribed || link_subscribed + } else { + true + } +} + +/// Check a user's subscription status, ignoring Patreon linkage +pub async fn check_user_subscription( + cache_http: impl CacheHttp, + user_id: impl Into, +) -> bool { if let Some(subscription_guild) = *CNC_GUILD { let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await; @@ -30,17 +81,6 @@ pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into, -) -> bool { - if let Some(owner) = cache_http.cache().unwrap().guild(guild_id).map(|g| g.owner_id) { - check_subscription(&cache_http, owner).await - } else { - false - } -} - pub fn reply_to_interaction_response_message( reply: CreateReply, ) -> CreateInteractionResponseMessage {