Add patreon-sharing option

This commit is contained in:
jude
2025-10-04 18:09:31 +01:00
parent 5ae4baa2a6
commit 91310d47d3
11 changed files with 232 additions and 30 deletions

View File

@@ -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)
);

View File

@@ -12,8 +12,6 @@ pub mod dashboard;
#[cfg(not(test))] #[cfg(not(test))]
pub mod delete; pub mod delete;
#[cfg(not(test))] #[cfg(not(test))]
pub mod donate;
#[cfg(not(test))]
pub mod help; pub mod help;
#[cfg(not(test))] #[cfg(not(test))]
pub mod info; pub mod info;
@@ -26,6 +24,8 @@ pub mod nudge;
#[cfg(not(test))] #[cfg(not(test))]
pub mod offset; pub mod offset;
#[cfg(not(test))] #[cfg(not(test))]
pub mod patreon;
#[cfg(not(test))]
pub mod pause; pub mod pause;
#[cfg(not(test))] #[cfg(not(test))]
pub mod remind; pub mod remind;

View File

@@ -15,8 +15,8 @@ impl Recordable for Options {
let footer = footer(ctx); let footer = footer(ctx);
ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate") ctx.send(CreateReply::default().embed(CreateEmbed::new().title("Donate")
.description("Thinking of adding a monthly contribution? .description("Thinking of subscribing?
Click below for my Patreon and official bot server :) Click below for my Patreon and official bot server
**https://www.patreon.com/jellywx/** **https://www.patreon.com/jellywx/**
**https://discord.jellywx.com/** **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 Set repeating reminders with `/remind` or the dashboard
Use unlimited uploads on SoundFX 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! Just $2 USD/month!
@@ -41,8 +41,8 @@ Just $2 USD/month!
} }
} }
/// Details on supporting the bot and Patreon benefits /// Show Patreon information
#[poise::command(slash_command, rename = "patreon", identifying_name = "patreon")] #[poise::command(slash_command, rename = "info", identifying_name = "patreon_info")]
pub async fn command(ctx: Context<'_>) -> Result<(), Error> { pub async fn info(ctx: Context<'_>) -> Result<(), Error> {
(Options {}).run(ctx).await (Options {}).run(ctx).await
} }

View File

@@ -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
}

View File

@@ -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(())
}

View File

@@ -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
}

View File

@@ -48,8 +48,8 @@ use crate::test::TestContext;
use crate::{ use crate::{
commands::{ commands::{
allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard, allowed_dm, clock, clock_context_menu::clock_context_menu, command_macro, dashboard,
delete, donate, help, info, look, multiline, nudge, offset, pause, remind, settings, timer, delete, help, info, look, multiline, nudge, offset, patreon, pause, remind, settings,
timezone, todo, webhook, timer, timezone, todo, webhook,
}, },
consts::THEME_COLOR, consts::THEME_COLOR,
event_handlers::listener, event_handlers::listener,
@@ -165,7 +165,14 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
help::command(), help::command(),
info::command(), info::command(),
clock::command(), clock::command(),
donate::command(), poise::Command {
subcommands: vec![
patreon::link::link(),
patreon::unlink::unlink(),
patreon::info::info(),
],
..patreon::command()
},
clock_context_menu(), clock_context_menu(),
dashboard::command(), dashboard::command(),
timezone::command(), timezone::command(),

View File

@@ -43,7 +43,7 @@ pub enum RecordedCommand {
#[serde(rename = "delete")] #[serde(rename = "delete")]
Delete(crate::commands::delete::Options), Delete(crate::commands::delete::Options),
#[serde(rename = "donate")] #[serde(rename = "donate")]
Donate(crate::commands::donate::Options), Donate(crate::commands::patreon::info::Options),
#[serde(rename = "help")] #[serde(rename = "help")]
Help(crate::commands::help::Options), Help(crate::commands::help::Options),
#[serde(rename = "info")] #[serde(rename = "info")]
@@ -111,7 +111,7 @@ impl RecordedCommand {
"clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))), "clock" => Some(Self::Clock(crate::commands::clock::Options::extract(ctx))),
"dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))), "dashboard" => Some(Self::Dashboard(crate::commands::dashboard::Options::extract(ctx))),
"delete" => Some(Self::Delete(crate::commands::delete::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))), "help" => Some(Self::Help(crate::commands::help::Options::extract(ctx))),
"info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))), "info" => Some(Self::Info(crate::commands::info::Options::extract(ctx))),
"look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))), "look" => Some(Self::Look(crate::commands::look::Options::extract(ctx))),

View File

@@ -17,7 +17,7 @@ use crate::{
CtxData, CtxData,
}, },
time_parser::{cron_next_timestamp, natural_parser}, time_parser::{cron_next_timestamp, natural_parser},
utils::{check_guild_subscription, check_subscription}, utils::check_subscription,
Context, Database, Error, Context, Database, Error,
}; };
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
@@ -532,10 +532,7 @@ pub async fn create_reminder(
}; };
let (processed_interval, processed_expires) = if let Some(repeat) = &interval { let (processed_interval, processed_expires) = if let Some(repeat) = &interval {
if check_subscription(&ctx, ctx.author().id).await if check_subscription(&ctx, ctx.author().id, ctx.guild_id()).await {
|| (ctx.guild_id().is_some()
&& check_guild_subscription(&ctx, ctx.guild_id().unwrap()).await)
{
( (
parse_duration(repeat) parse_duration(repeat)
.or_else(|_| parse_duration(&format!("1 {}", repeat))) .or_else(|_| parse_duration(&format!("1 {}", repeat)))

View File

@@ -3,6 +3,8 @@ use poise::{
CreateReply, CreateReply,
}; };
use serde_json::Value; use serde_json::Value;
use serenity::all::Http;
use serenity::http::CacheHttp;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{Data, Error}; use crate::{Data, Error};
@@ -19,6 +21,12 @@ pub(crate) struct TestContext<'a> {
pub(crate) shard_id: usize, pub(crate) shard_id: usize,
} }
impl CacheHttp for TestContext<'_> {
fn http(&self) -> &Http {
todo!()
}
}
pub(crate) struct MockUser { pub(crate) struct MockUser {
pub(crate) id: UserId, pub(crate) id: UserId,
} }

View File

@@ -12,7 +12,58 @@ use crate::{
ApplicationContext, Context, Error, ApplicationContext, Context, Error,
}; };
pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into<UserId>) -> 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<GuildId>,
) -> 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<UserId>,
) -> bool {
if let Some(subscription_guild) = *CNC_GUILD { if let Some(subscription_guild) = *CNC_GUILD {
let guild_member = GuildId::new(subscription_guild).member(cache_http, user_id).await; 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<U
} }
} }
pub async fn check_guild_subscription(
cache_http: impl CacheHttp,
guild_id: impl Into<GuildId>,
) -> 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( pub fn reply_to_interaction_response_message(
reply: CreateReply, reply: CreateReply,
) -> CreateInteractionResponseMessage { ) -> CreateInteractionResponseMessage {