diff --git a/sqlx-data.json b/sqlx-data.json index 7c60b6f5..fa72ee81 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -39,6 +39,18 @@ }, "query": "INSERT INTO members (user_id, guild_id) VALUES ($1, $2) RETURNING *" }, + "018749d2c5df13cc9bcefe6a7cad5d957b60cdb4631d72727c1ff2f690cb5494": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4Array" + ] + } + }, + "query": "UPDATE autostar_channels SET premium_locked=true WHERE id=any($1)" + }, "04b5d7f155912898490d7858fdcb12e19e97f14ed5e8c89c727880fcaecfd376": { "describe": { "columns": [ @@ -168,6 +180,24 @@ }, "query": "DELETE FROM permroles WHERE role_id=$1 RETURNING *" }, + "07968e9d46bb8092416c850fa3b0af62f9a28d603d7d3cc5877690e2c9c3ef4c": { + "describe": { + "columns": [ + { + "name": "channel_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [] + } + }, + "query": "SELECT DISTINCT channel_id FROM autostar_channels WHERE premium_locked=false" + }, "08d0ded05e0b03c89892109f492fee7fa6e7a47afd18bf4155524593ce92aaca": { "describe": { "columns": [ @@ -239,7 +269,7 @@ }, "query": "SELECT * FROM overrides WHERE guild_id=$1 AND name=$2" }, - "16edc7667e56b3d3aad9ff4d1bb32de3bd233361e9577eb449c58e3bbfa01ca0": { + "0f8091a3bbaf9d14d2fec8eb5467d2b350b6175d35596e5576b4afb078f8bc7b": { "describe": { "columns": [ { @@ -252,10 +282,32 @@ false ], "parameters": { - "Left": [] + "Left": [ + "Int8" + ] + } + }, + "query": "UPDATE autostar_channels SET premium_locked=false WHERE guild_id=$1\n RETURNING channel_id" + }, + "18672a6e3dd3c7d703acbbd3ded2d36793f5f7272eedd8b47d468c4ec0959621": { + "describe": { + "columns": [ + { + "name": "guild_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] } }, - "query": "SELECT channel_id FROM autostar_channels" + "query": "SELECT guild_id FROM members WHERE user_id=$1 AND autoredeem_enabled=true" }, "1e3823ed8de2274b2c2a479c0a03934943a9559bfbd646bf44b43b0d2a02fb6c": { "describe": { @@ -567,6 +619,32 @@ }, "query": "SELECT * FROM members WHERE guild_id=$1 AND user_id=$2" }, + "22d422ce0c5ebeed7f0281b848e9cf0bed37de6a45d1c211367530323bcb1b34": { + "describe": { + "columns": [ + { + "name": "guild_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "premium_end", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT * FROM guilds WHERE guild_id=$1 FOR UPDATE" + }, "234c6ea2740620a51ee59fd0dcbe4a54fa4be45c02312f69c7bdfa81e9e43ee3": { "describe": { "columns": [ @@ -1095,6 +1173,120 @@ }, "query": "INSERT INTO users\n (user_id, is_bot)\n VALUES ($1, $2)\n RETURNING *" }, + "2dbccc87a00302144edb336fe6c8d8f74bb00dd1fc1db8b31c63ede846dfc899": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + } + }, + "query": "UPDATE users SET credits = credits - $1 WHERE user_id=$2" + }, + "2dc58b352d1b425b1b3c94e71b0b4f3dfd052c19dddd79f767a82fad9c78dc7f": { + "describe": { + "columns": [ + { + "name": "guild_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "premium_end", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT * FROM guilds WHERE guild_id=$1" + }, + "373e7d1fb45257f07862ef685e9e83c1b22dba9ecef707fd904f12a491ca4f22": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "guild_id", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "premium_locked", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "emojis", + "ordinal": 5, + "type_info": "TextArray" + }, + { + "name": "min_chars", + "ordinal": 6, + "type_info": "Int2" + }, + { + "name": "max_chars", + "ordinal": 7, + "type_info": "Int2" + }, + { + "name": "require_image", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "delete_invalid", + "ordinal": 9, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "SELECT * FROM autostar_channels WHERE guild_id=$1 AND name=$2 FOR UPDATE" + }, "374879d017c0a2ea236967ecdf63041b0ae471ee8d8beb4ff8c1824fdbd09116": { "describe": { "columns": [ @@ -1534,6 +1726,18 @@ }, "query": "DELETE FROM autostar_channels\n WHERE name=$1 AND guild_id=$2\n RETURNING *" }, + "4d59d25e423e791b1e483bdd9724e27ec3df3cb369ff29c12c1d4b77a97395d0": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4" + ] + } + }, + "query": "UPDATE starboards SET premium_locked=true WHERE id=$1" + }, "58686e2078ceb6965927ac04490429be6ed392ce1f7a4e5c4d960442439f0314": { "describe": { "columns": [ @@ -1611,6 +1815,18 @@ }, "query": "INSERT INTO permroles\n (role_id, guild_id)\n VALUES ($1, $2)\n RETURNING *" }, + "5b5183b245e820663f9fd4de0e54aa73fa17eef66db784ce8c0d2d639b89d5af": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4" + ] + } + }, + "query": "UPDATE autostar_channels SET premium_locked=true WHERE id=$1" + }, "5d841328e429c98bdd5dbff9f22ad7a2fcaec557086e64afb120e13888cfbc1f": { "describe": { "columns": [ @@ -1759,6 +1975,38 @@ }, "query": "UPDATE members SET xp=$1 WHERE user_id=$2 AND guild_id=$3 RETURNING *" }, + "624cc73a723098f569d744a61b7ea796a22c550cc4cb65d38d94df268b4d88b0": { + "describe": { + "columns": [ + { + "name": "guild_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + } + }, + "query": "UPDATE guilds SET premium_end=null WHERE premium_end IS NOT NULL AND premium_end < $1\n RETURNING guild_id" + }, + "625bb0253d63e0f0cb858363d90a379138429e6e84376cabb0dfe4fb6168a050": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4" + ] + } + }, + "query": "UPDATE starboards SET premium_locked=false WHERE id=$1" + }, "62dfbdacb72b9b8d9dba195684068e31c3daddf4e9682041c8a87697c9bebd7d": { "describe": { "columns": [ @@ -1890,46 +2138,90 @@ }, "query": "UPDATE starboards SET webhook_id=$1 WHERE id=$2" }, - "6b5b308d63aea59fd9add4f55683542950d367bcea2800e55f0e1f059cc575e8": { + "69f2d397b93fb222e6076507f22afef9e02e567908deaf3e0482dbb173066a58": { "describe": { "columns": [ { - "name": "id", + "name": "user_id", "ordinal": 0, - "type_info": "Int4" + "type_info": "Int8" }, { - "name": "name", + "name": "is_bot", "ordinal": 1, - "type_info": "Text" + "type_info": "Bool" }, { - "name": "channel_id", + "name": "credits", "ordinal": 2, - "type_info": "Int8" + "type_info": "Int4" }, { - "name": "guild_id", + "name": "donated_cents", "ordinal": 3, "type_info": "Int8" }, { - "name": "premium_locked", + "name": "patreon_status", "ordinal": 4, - "type_info": "Bool" - }, - { - "name": "emojis", - "ordinal": 5, - "type_info": "TextArray" - }, - { - "name": "min_chars", - "ordinal": 6, "type_info": "Int2" - }, - { - "name": "max_chars", + } + ], + "nullable": [ + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT * FROM users WHERE user_id=$1 FOR UPDATE" + }, + "6b5b308d63aea59fd9add4f55683542950d367bcea2800e55f0e1f059cc575e8": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "guild_id", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "premium_locked", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "emojis", + "ordinal": 5, + "type_info": "TextArray" + }, + { + "name": "min_chars", + "ordinal": 6, + "type_info": "Int2" + }, + { + "name": "max_chars", "ordinal": 7, "type_info": "Int2" }, @@ -2028,6 +2320,26 @@ }, "query": "SELECT * FROM xproles WHERE guild_id=$1 ORDER BY required DESC" }, + "71da158d194f732eb561670f52249305966b553f38ebf7839b0a5064f02c0ebf": { + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT count(*) as count FROM autostar_channels WHERE guild_id=$1 AND \n premium_locked=false" + }, "7584c85655f379fc8a95a4b0fe7e3d060711300ba1ec6e28d01bd7536ba4c53b": { "describe": { "columns": [ @@ -2595,6 +2907,47 @@ }, "query": "UPDATE starboards SET name=$1 WHERE name=$2 AND guild_id=$3\n RETURNING *" }, + "807200d602df1c43433036aaba5c7116dd1b50fb7ca792f4aa78c243f0f84229": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT id FROM starboards WHERE guild_id=$1 LIMIT $2" + }, + "808413db7d391b1d278bcdf3e12cbd4d9825180b02775ef70c62e0998d629782": { + "describe": { + "columns": [ + { + "name": "count", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT count(*) as count FROM starboards WHERE guild_id=$1 AND premium_locked=false" + }, "843c355ffb0acbd4e70a883ead71d8d03488e7e3cc22e60cd410996e81be759d": { "describe": { "columns": [ @@ -2786,6 +3139,26 @@ }, "query": "UPDATE overrides SET overrides=$1 WHERE id=$2 RETURNING *" }, + "8e1d277d941255a7c289675b488a87f25a4732d590e93935b41f8465e71b98ce": { + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT user_id FROM members WHERE autoredeem_enabled=true AND guild_id=$1" + }, "8ebe2f3f7ea74aa02940f579111f68e03c429e711fd5e7dd808eafaf47a8e394": { "describe": { "columns": [], @@ -2893,6 +3266,18 @@ }, "query": "SELECT * FROM autostar_channels WHERE channel_id = $1" }, + "97abcbb4aaaf6f0fe7b72030f36ee861e41dfb38c6cd7d0b53159c8fa44f920e": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "UPDATE starboards SET premium_locked=false WHERE guild_id=$1" + }, "9d4eb12dd28fd734427d327dcfef36db627cc97700fa3213217ac6f577e75206": { "describe": { "columns": [ @@ -3749,6 +4134,20 @@ }, "query": "DELETE FROM xproles WHERE role_id=$1 RETURNING *" }, + "ca6765443683c4135bf4fa61d4934e5a235e582766b7bb9b0c48cf89ebf485c8": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Bool", + "Int8", + "Int8" + ] + } + }, + "query": "UPDATE members SET autoredeem_enabled=$1 WHERE user_id=$2 AND guild_id=$3" + }, "cb9b3ce9131ac41057770317c912a7c71f79dfc2818f9d0f547acb9939516027": { "describe": { "columns": [ @@ -3808,6 +4207,18 @@ }, "query": "SELECT * FROM starboard_messages WHERE\n starboard_message_id=$1" }, + "d2dd5c48fa02b14f38ff569070f4c430279774fea649afbf2c40ae66b15c581b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4" + ] + } + }, + "query": "UPDATE autostar_channels SET premium_locked=false WHERE id=$1" + }, "d342b1c18ab9fb5e12a674ed5c79ca966aed58a66788d62160655491f5159ead": { "describe": { "columns": [ @@ -3828,6 +4239,18 @@ }, "query": "SELECT COUNT(*) as count FROM permroles WHERE guild_id=$1" }, + "d4f19cc69a92ec5ad0d9d99faa90965a4b60e6203fc76d608db0602a896cb74a": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4Array" + ] + } + }, + "query": "UPDATE starboards SET premium_locked=true WHERE id=any($1)" + }, "db47dd094d91342ad83996e3ebe7cacc85c7f77f1e7ecd0c9d8ba6914fb9ec8a": { "describe": { "columns": [ @@ -3900,6 +4323,19 @@ }, "query": "UPDATE posroles SET max_members=$1 WHERE role_id=$2 RETURNING *" }, + "dcb3fb61d0ac4850f9f1447e3ccbbab1825e0642c00e56cdad0114b51c04cfb0": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Timestamptz", + "Int8" + ] + } + }, + "query": "UPDATE guilds SET premium_end=$1 WHERE guild_id=$2" + }, "de87a260c267723bfafe35d9638f4475b4fbb1ef85b95db45fd768a4fe69fce6": { "describe": { "columns": [ @@ -4036,6 +4472,231 @@ }, "query": "SELECT * FROM overrides WHERE starboard_id=$1" }, + "e19e740c0aac84531493f2ed3c6060bb4ca5bb1e6a7d9bece15c8d1b435920c1": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 2, + "type_info": "Int8" + }, + { + "name": "guild_id", + "ordinal": 3, + "type_info": "Int8" + }, + { + "name": "webhook_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "premium_locked", + "ordinal": 5, + "type_info": "Bool" + }, + { + "name": "display_emoji", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "ping_author", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "use_server_profile", + "ordinal": 8, + "type_info": "Bool" + }, + { + "name": "extra_embeds", + "ordinal": 9, + "type_info": "Bool" + }, + { + "name": "use_webhook", + "ordinal": 10, + "type_info": "Bool" + }, + { + "name": "color", + "ordinal": 11, + "type_info": "Int4" + }, + { + "name": "jump_to_message", + "ordinal": 12, + "type_info": "Bool" + }, + { + "name": "attachments_list", + "ordinal": 13, + "type_info": "Bool" + }, + { + "name": "replied_to", + "ordinal": 14, + "type_info": "Bool" + }, + { + "name": "required", + "ordinal": 15, + "type_info": "Int2" + }, + { + "name": "required_remove", + "ordinal": 16, + "type_info": "Int2" + }, + { + "name": "upvote_emojis", + "ordinal": 17, + "type_info": "TextArray" + }, + { + "name": "downvote_emojis", + "ordinal": 18, + "type_info": "TextArray" + }, + { + "name": "self_vote", + "ordinal": 19, + "type_info": "Bool" + }, + { + "name": "allow_bots", + "ordinal": 20, + "type_info": "Bool" + }, + { + "name": "require_image", + "ordinal": 21, + "type_info": "Bool" + }, + { + "name": "older_than", + "ordinal": 22, + "type_info": "Int8" + }, + { + "name": "newer_than", + "ordinal": 23, + "type_info": "Int8" + }, + { + "name": "enabled", + "ordinal": 24, + "type_info": "Bool" + }, + { + "name": "autoreact_upvote", + "ordinal": 25, + "type_info": "Bool" + }, + { + "name": "autoreact_downvote", + "ordinal": 26, + "type_info": "Bool" + }, + { + "name": "remove_invalid_reactions", + "ordinal": 27, + "type_info": "Bool" + }, + { + "name": "link_deletes", + "ordinal": 28, + "type_info": "Bool" + }, + { + "name": "link_edits", + "ordinal": 29, + "type_info": "Bool" + }, + { + "name": "private", + "ordinal": 30, + "type_info": "Bool" + }, + { + "name": "xp_multiplier", + "ordinal": 31, + "type_info": "Float4" + }, + { + "name": "cooldown_enabled", + "ordinal": 32, + "type_info": "Bool" + }, + { + "name": "cooldown_count", + "ordinal": 33, + "type_info": "Int2" + }, + { + "name": "cooldown_period", + "ordinal": 34, + "type_info": "Int2" + } + ], + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + } + }, + "query": "SELECT * FROM starboards WHERE guild_id=$1 AND name=$2 FOR UPDATE" + }, "e250783122814fbe8bbf17a71a754951bb1907baede1273a7422a6e4ec6a82f3": { "describe": { "columns": [ @@ -4205,6 +4866,27 @@ }, "query": "DELETE FROM starboard_messages WHERE starboard_message_id=$1\n RETURNING *" }, + "ea4387441dc21ba831cdeedc41d8b94f5ebc09b7ada5af51fe76adb5a5c89f89": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int4" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT id FROM autostar_channels WHERE guild_id=$1 LIMIT $2" + }, "f593aa1cbdba23466e32e06ffbd59144e044dfad3f355cdb385b6941d28580ea": { "describe": { "columns": [ diff --git a/src/cache/events/guild.rs b/src/cache/events/guild.rs index 8f7511fb..ba78b372 100644 --- a/src/cache/events/guild.rs +++ b/src/cache/events/guild.rs @@ -19,6 +19,7 @@ impl UpdateCache for GuildCreate { .collect(); let guild = CachedGuild { + name: self.name.clone(), emojis: self.emojis.iter().map(|e| (e.id, e.animated)).collect(), channels, active_thread_parents: self diff --git a/src/cache/models/guild.rs b/src/cache/models/guild.rs index 41694f44..f1d1fc52 100644 --- a/src/cache/models/guild.rs +++ b/src/cache/models/guild.rs @@ -8,6 +8,7 @@ use twilight_model::id::{ use super::{channel::CachedChannel, member::CachedMember}; pub struct CachedGuild { + pub name: String, /// all custom emojis mapped to whether they are animated pub emojis: HashMap, bool>, /// all textable channels except for threads diff --git a/src/client/bot.rs b/src/client/bot.rs index 7ca7825c..c90af309 100644 --- a/src/client/bot.rs +++ b/src/client/bot.rs @@ -1,5 +1,9 @@ -use std::fmt::{Debug, Write}; +use std::{ + fmt::{Debug, Write}, + sync::Arc, +}; +use futures::Future; use snafu::ErrorCompat; use sqlx::PgPool; use tokio::sync::RwLock; @@ -71,12 +75,14 @@ impl StarboardBot { .expect("failed to run migrations"); // load autostar channels - let asc: Vec<_> = sqlx::query!("SELECT channel_id FROM autostar_channels") - .fetch_all(&pool) - .await? - .into_iter() - .map(|rec| Id::::new(rec.channel_id as u64)) - .collect(); + let asc: Vec<_> = sqlx::query!( + "SELECT DISTINCT channel_id FROM autostar_channels WHERE premium_locked=false" + ) + .fetch_all(&pool) + .await? + .into_iter() + .map(|rec| Id::::new(rec.channel_id as u64)) + .collect(); let mut map = dashmap::DashSet::new(); map.extend(asc); @@ -140,4 +146,13 @@ impl StarboardBot { } } } + + pub async fn catch_future_errors>( + bot: Arc, + future: impl Future>, + ) { + if let Err(err) = future.await { + bot.handle_error(&err.into()).await; + } + } } diff --git a/src/client/runner.rs b/src/client/runner.rs index f000b3a9..e03c6620 100644 --- a/src/client/runner.rs +++ b/src/client/runner.rs @@ -8,7 +8,9 @@ use tokio::{ use twilight_gateway::cluster::Events; use crate::{ - client::bot::StarboardBot, core::posroles::loop_update_posroles, events::handle_event, + client::bot::StarboardBot, + core::{posroles::loop_update_posroles, premium::expire::loop_expire_premium}, + events::handle_event, }; use super::cooldowns::Cooldowns; @@ -46,6 +48,7 @@ pub async fn run(mut events: Events, bot: StarboardBot) { // start background tasks tokio::spawn(loop_update_posroles(bot.clone())); + tokio::spawn(loop_expire_premium(bot.clone())); // handle events while let Some((shard_id, event)) = events.next().await { diff --git a/src/constants.rs b/src/constants.rs index a42e0f05..eb879e54 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -8,6 +8,9 @@ pub const BOT_COLOR: u32 = 0xFFE19C; pub const YEAR_SECONDS: i64 = 31_557_600; pub const MONTH_SECONDS: i64 = 2_630_016; +pub const MONTH_DAYS: u64 = 31; + +pub const CREDITS_PER_MONTH: u64 = 3; // Links pub const INVITE_URL: &str = "https://discord.com/api/oauth2/authorize?client_id=700796664276844612&permissions=805661760&scope=bot%20applications.commands"; @@ -20,6 +23,7 @@ pub const REVIEW_URL: &str = "https://top.gg/bot/700796664276844612#reviews"; // Tasks pub const UPDATE_PRS_DELAY: Duration = Duration::from_secs(60 * 60); +pub const CHECK_EXPIRED_PREMIUM: Duration = Duration::from_secs(60 * 60); // Cache size pub const MAX_MESSAGES: u32 = 10_000; @@ -39,7 +43,9 @@ pub const MAX_MAX_CHARS: i16 = 5_000; pub const MAX_MIN_CHARS: i16 = 5_000; pub const MAX_ASC_EMOJIS: usize = 3; +pub const MAX_PREM_ASC_EMOJIS: usize = 10; pub const MAX_AUTOSTAR: i64 = 3; +pub const MAX_PREM_AUTOSTAR: i64 = 10; // Starboard Validation pub const MIN_REQUIRED: i16 = 1; @@ -57,7 +63,9 @@ pub const MAX_NEWER_THAN: i64 = YEAR_SECONDS * 50; pub const MAX_OLDER_THAN: i64 = YEAR_SECONDS * 50; pub const MAX_VOTE_EMOJIS: usize = 3; +pub const MAX_PREM_VOTE_EMOJIS: usize = 10; pub const MAX_STARBOARDS: i64 = 3; +pub const MAX_PREM_STARBOARDS: i64 = 10; // Override Validation pub const MAX_CHANNELS_PER_OVERRIDE: usize = 100; diff --git a/src/core/autostar.rs b/src/core/autostar.rs index e2657f23..b346feac 100644 --- a/src/core/autostar.rs +++ b/src/core/autostar.rs @@ -44,6 +44,7 @@ pub async fn handle( // Fetch the autostar channels let asc = AutoStarChannel::list_by_channel(&bot.pool, autostar_channel_id.get_i64()).await?; + let asc: Vec<_> = asc.into_iter().filter(|a| !a.premium_locked).collect(); // If none, remove the channel id from the cache if asc.is_empty() { diff --git a/src/core/embedder/handle.rs b/src/core/embedder/handle.rs index dde38d25..8be62a20 100644 --- a/src/core/embedder/handle.rs +++ b/src/core/embedder/handle.rs @@ -5,7 +5,10 @@ use twilight_model::id::{marker::MessageMarker, Id}; use crate::{ cache::models::{message::CachedMessage, user::CachedUser}, client::bot::StarboardBot, - core::starboard::{config::StarboardConfig, webhooks::get_valid_webhook}, + core::{ + premium::is_premium::is_guild_premium, + starboard::{config::StarboardConfig, webhooks::get_valid_webhook}, + }, database::{Message as DbMessage, Starboard}, errors::StarboardResult, utils::{get_status::get_status, id_as_i64::GetI64, into_id::IntoId}, @@ -33,18 +36,25 @@ impl Embedder<'_, '_> { &self, bot: &StarboardBot, ) -> StarboardResult { - let built = match self.build(false, self.config.resolved.use_webhook) { + let guild_id = self.config.starboard.guild_id.into_id(); + let sb_channel_id = self.config.starboard.channel_id.into_id(); + + let is_prem = is_guild_premium(bot, self.config.starboard.guild_id).await?; + + let built = match self.build(false, self.config.resolved.use_webhook && !is_prem) { BuiltStarboardEmbed::Full(built) => built, BuiltStarboardEmbed::Partial(_) => panic!("Tried to send an unbuildable message."), }; - let (attachments, errors) = built.upload_attachments.as_attachments(bot).await; - - for e in errors { - bot.handle_error(&e).await; - } - let guild_id = self.config.starboard.guild_id.into_id(); - let sb_channel_id = self.config.starboard.channel_id.into_id(); + let attachments = if is_prem { + let (attachments, errors) = built.upload_attachments.as_attachments(bot).await; + for e in errors { + bot.handle_error(&e).await; + } + Some(attachments) + } else { + None + }; let forum_post_name = if bot.cache.is_channel_forum(guild_id, sb_channel_id) { let name = &built.embeds[0].author.as_ref().unwrap().name; @@ -76,8 +86,11 @@ impl Embedder<'_, '_> { .http .execute_webhook(wh.id, wh.token.as_ref().unwrap()) .content(&built.top_content)? - .embeds(&built.embeds)? - .attachments(&attachments)?; + .embeds(&built.embeds)?; + + if let Some(attachments) = &attachments { + ret = ret.attachments(attachments)?; + } if parent != sb_channel_id { ret = ret.thread_id(sb_channel_id); @@ -106,28 +119,30 @@ impl Embedder<'_, '_> { } if let Some(name) = forum_post_name { - let ret = bot + let mut ret = bot .http .create_forum_thread(sb_channel_id, &name) .message() .content(&built.top_content)? - .embeds(&built.embeds)? - .attachments(&attachments)? - .await? - .model() - .await?; + .embeds(&built.embeds)?; + + if let Some(attachments) = &attachments { + ret = ret.attachments(attachments)?; + }; - Ok(ret.message) + Ok(ret.await?.model().await?.message) } else { - Ok(bot + let mut ret = bot .http .create_message(self.config.starboard.channel_id.into_id()) .content(&built.top_content)? - .embeds(&built.embeds)? - .attachments(&attachments)? - .await? - .model() - .await?) + .embeds(&built.embeds)?; + + if let Some(attachments) = &attachments { + ret = ret.attachments(attachments)?; + } + + Ok(ret.await?.model().await?) } } @@ -169,7 +184,9 @@ impl Embedder<'_, '_> { (None, false) }; - match self.build(force_partial, wh.is_some()) { + let is_prem = is_guild_premium(bot, self.config.starboard.guild_id).await?; + + match self.build(force_partial, wh.is_some() && !is_prem) { BuiltStarboardEmbed::Full(built) => { if let Some(wh) = wh { let mut ud = bot diff --git a/src/core/mod.rs b/src/core/mod.rs index e0ce565b..310c0f1e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -4,6 +4,7 @@ pub mod emoji; pub mod has_image; pub mod permroles; pub mod posroles; +pub mod premium; pub mod starboard; pub mod stats; pub mod xproles; diff --git a/src/core/posroles.rs b/src/core/posroles.rs index dea8153e..c5323c5c 100644 --- a/src/core/posroles.rs +++ b/src/core/posroles.rs @@ -13,6 +13,8 @@ use crate::{ utils::{id_as_i64::GetI64, into_id::IntoId}, }; +use super::premium::is_premium::is_guild_premium; + pub struct GuildPRUpdateResult { pub removed_roles: i32, pub added_roles: i32, @@ -28,16 +30,45 @@ pub async fn loop_update_posroles(bot: Arc) { .fetch_all(&bot.pool) .await; - let Ok(guilds) = guilds else { - sentry::capture_message("Updating posroles failed due to query.", sentry::Level::Error); - continue; + let guilds = match guilds { + Ok(guilds) => guilds, + Err(err) => { + bot.handle_error(&err.into()).await; + continue; + } }; + let mut tasks = Vec::new(); for guild in guilds { - tokio::spawn(update_posroles_for_guild( + let is_prem = match is_guild_premium(&bot, guild.guild_id).await { + Ok(is_prem) => is_prem, + Err(why) => { + bot.handle_error(&why).await; + continue; + } + }; + if !is_prem { + continue; + } + let task = tokio::spawn(update_posroles_for_guild( bot.clone(), guild.guild_id.into_id(), )); + tasks.push(task); + } + + for t in tasks { + let ret = t.await; + let ret = match ret { + Ok(ret) => ret, + Err(err) => { + bot.handle_error(&err.into()).await; + continue; + } + }; + if let Err(err) = ret { + bot.handle_error(&err.into()).await; + } } } } diff --git a/src/core/premium/expire.rs b/src/core/premium/expire.rs new file mode 100644 index 00000000..1ed94bd9 --- /dev/null +++ b/src/core/premium/expire.rs @@ -0,0 +1,63 @@ +use futures::TryStreamExt; +use std::sync::Arc; + +use crate::{ + client::bot::StarboardBot, constants, core::premium::locks::refresh_premium_locks, + errors::StarboardResult, +}; + +use super::{ + is_premium::is_guild_premium, + redeem::{redeem_premium, RedeemPremiumResult}, +}; + +pub async fn loop_expire_premium(bot: Arc) { + loop { + tokio::time::sleep(constants::CHECK_EXPIRED_PREMIUM).await; + + if let Err(err) = check_expired_premium(bot.clone()).await { + bot.handle_error(&err).await; + } + } +} + +async fn check_expired_premium(bot: Arc) -> StarboardResult<()> { + let expired_guilds = sqlx::query!( + "UPDATE guilds SET premium_end=null WHERE premium_end IS NOT NULL AND premium_end < $1 + RETURNING guild_id", + chrono::Utc::now(), + ) + .fetch_all(&bot.pool) + .await?; + + for guild in expired_guilds { + tokio::spawn(StarboardBot::catch_future_errors( + bot.clone(), + process_expired_guild(bot.clone(), guild.guild_id), + )); + } + + Ok(()) +} + +async fn process_expired_guild(bot: Arc, guild_id: i64) -> StarboardResult<()> { + let mut stream = sqlx::query!( + "SELECT user_id FROM members WHERE autoredeem_enabled=true AND guild_id=$1", + guild_id + ) + .fetch(&bot.pool); + + while let Some(member) = stream.try_next().await? { + let ret = redeem_premium(&bot, member.user_id, guild_id, 1, Some(None)).await?; + + match ret { + RedeemPremiumResult::Ok => break, // successfully added premium + RedeemPremiumResult::StateMismatch => break, // the server has premium now + RedeemPremiumResult::TooFewCredits => (), // try another member + } + } + + refresh_premium_locks(&bot, guild_id, is_guild_premium(&bot, guild_id).await?).await?; + + Ok(()) +} diff --git a/src/core/premium/is_premium.rs b/src/core/premium/is_premium.rs new file mode 100644 index 00000000..8bfcbde0 --- /dev/null +++ b/src/core/premium/is_premium.rs @@ -0,0 +1,9 @@ +use crate::{client::bot::StarboardBot, database::Guild, errors::StarboardResult}; + +pub async fn is_guild_premium(bot: &StarboardBot, guild_id: i64) -> StarboardResult { + if let Some(guild) = Guild::get(&bot.pool, guild_id).await? { + Ok(guild.premium_end.is_some()) + } else { + Ok(false) + } +} diff --git a/src/core/premium/locks.rs b/src/core/premium/locks.rs new file mode 100644 index 00000000..4ecb1c6f --- /dev/null +++ b/src/core/premium/locks.rs @@ -0,0 +1,89 @@ +use crate::{ + client::bot::StarboardBot, constants, errors::StarboardResult, utils::into_id::IntoId, +}; + +pub async fn refresh_premium_locks( + bot: &StarboardBot, + guild_id: i64, + premium: bool, +) -> StarboardResult<()> { + // unlock everything first + sqlx::query!( + "UPDATE starboards SET premium_locked=false WHERE guild_id=$1", + guild_id + ) + .fetch_all(&bot.pool) + .await?; + let unlocked_asc_channel_ids = sqlx::query!( + "UPDATE autostar_channels SET premium_locked=false WHERE guild_id=$1 + RETURNING channel_id", + guild_id + ) + .fetch_all(&bot.pool) + .await?; + for row in unlocked_asc_channel_ids { + bot.cache + .autostar_channel_ids + .insert(row.channel_id.into_id()); + } + + // if premium, just return + if premium { + return Ok(()); + } + + // lock starboards + let count = sqlx::query!( + "SELECT count(*) as count FROM starboards WHERE guild_id=$1 AND premium_locked=false", + guild_id + ) + .fetch_one(&bot.pool) + .await? + .count + .unwrap(); + let to_lock = count - constants::MAX_STARBOARDS; + if to_lock > 0 { + let sb_ids = sqlx::query!( + "SELECT id FROM starboards WHERE guild_id=$1 LIMIT $2", + guild_id, + to_lock + ) + .fetch_all(&bot.pool) + .await?; + sqlx::query!( + "UPDATE starboards SET premium_locked=true WHERE id=any($1)", + &sb_ids.into_iter().map(|r| r.id).collect::>() + ) + .fetch_all(&bot.pool) + .await?; + } + + // lock autostar channels + let count = sqlx::query!( + "SELECT count(*) as count FROM autostar_channels WHERE guild_id=$1 AND + premium_locked=false", + guild_id + ) + .fetch_one(&bot.pool) + .await? + .count + .unwrap(); + let to_lock = count - constants::MAX_AUTOSTAR; + if to_lock > 0 { + let asc_ids = sqlx::query!( + "SELECT id FROM autostar_channels WHERE guild_id=$1 LIMIT $2", + guild_id, + to_lock + ) + .fetch_all(&bot.pool) + .await?; + sqlx::query!( + "UPDATE autostar_channels SET premium_locked=true WHERE id=any($1)", + &asc_ids.into_iter().map(|r| r.id).collect::>() + ) + .fetch_all(&bot.pool) + .await?; + } + + Ok(()) +} diff --git a/src/core/premium/mod.rs b/src/core/premium/mod.rs new file mode 100644 index 00000000..9447a0cc --- /dev/null +++ b/src/core/premium/mod.rs @@ -0,0 +1,4 @@ +pub mod expire; +pub mod is_premium; +pub mod locks; +pub mod redeem; diff --git a/src/core/premium/redeem.rs b/src/core/premium/redeem.rs new file mode 100644 index 00000000..96f7cbde --- /dev/null +++ b/src/core/premium/redeem.rs @@ -0,0 +1,86 @@ +use chrono::{DateTime, Days, Utc}; + +use crate::{ + client::bot::StarboardBot, + constants, + database::{Guild, User}, + errors::StarboardResult, + map_dup_none, +}; + +#[derive(PartialEq, Eq)] +pub enum RedeemPremiumResult { + Ok, + StateMismatch, + TooFewCredits, +} + +pub async fn redeem_premium( + bot: &StarboardBot, + user_id: i64, + guild_id: i64, + months: u64, + assert_guild_status: Option>>, +) -> StarboardResult { + map_dup_none!(Guild::create(&bot.pool, guild_id))?; + + let credits = months * constants::CREDITS_PER_MONTH; + + let mut tx = bot.pool.begin().await?; + + // get the guild + let guild: Guild = sqlx::query_as!( + Guild, + "SELECT * FROM guilds WHERE guild_id=$1 FOR UPDATE", + guild_id + ) + .fetch_one(&mut tx) + .await?; + if let Some(assert_guild_status) = assert_guild_status { + if guild.premium_end != assert_guild_status { + return Ok(RedeemPremiumResult::StateMismatch); + } + } + + // get the user + let user: Option = sqlx::query_as!( + User, + "SELECT * FROM users WHERE user_id=$1 FOR UPDATE", + user_id + ) + .fetch_optional(&mut tx) + .await?; + let Some(user) = user else { + return Ok(RedeemPremiumResult::TooFewCredits); + }; + if user.credits < credits as i32 { + return Ok(RedeemPremiumResult::TooFewCredits); + } + + // calculate and set the new premium end + let new_end = if let Some(old_end) = guild.premium_end { + old_end + Days::new(constants::MONTH_DAYS * months) + } else { + Utc::now() + Days::new(constants::MONTH_DAYS * months) + }; + + sqlx::query!( + "UPDATE guilds SET premium_end=$1 WHERE guild_id=$2", + new_end, + guild_id + ) + .fetch_all(&mut tx) + .await?; + sqlx::query!( + "UPDATE users SET credits = credits - $1 WHERE user_id=$2", + credits as i32, + user_id, + ) + .fetch_all(&mut tx) + .await?; + + // commit the transaction and return + tx.commit().await?; + + Ok(RedeemPremiumResult::Ok) +} diff --git a/src/core/starboard/handle.rs b/src/core/starboard/handle.rs index 53445c5e..e754dce0 100644 --- a/src/core/starboard/handle.rs +++ b/src/core/starboard/handle.rs @@ -50,6 +50,10 @@ impl RefreshMessage<'_> { let mut errors = Vec::new(); for c in configs.iter() { + if !c.resolved.enabled || c.starboard.premium_locked { + continue; + } + if let Err(why) = RefreshStarboard::new(self, c).refresh(force).await { errors.push(why); } diff --git a/src/core/stats.rs b/src/core/stats.rs index 79deb2df..fed67e93 100644 --- a/src/core/stats.rs +++ b/src/core/stats.rs @@ -10,7 +10,7 @@ use crate::{ utils::id_as_i64::GetI64, }; -use super::xproles::refresh_xpr; +use super::{premium::is_premium::is_guild_premium, xproles::refresh_xpr}; #[derive(Default)] pub struct MemberStats { @@ -144,7 +144,9 @@ pub async fn refresh_xp( Member::set_xp(&bot.pool, user_id.get_i64(), guild_id.get_i64(), stats.xp).await?; - refresh_xpr(bot, guild_id, user_id).await?; + if is_guild_premium(bot, guild_id.get_i64()).await? { + refresh_xpr(bot, guild_id, user_id).await?; + } Ok(()) } diff --git a/src/database/models/autostar_channel.rs b/src/database/models/autostar_channel.rs index 6a52ba1f..4a9f66bc 100644 --- a/src/database/models/autostar_channel.rs +++ b/src/database/models/autostar_channel.rs @@ -150,11 +150,18 @@ impl AutoStarChannel { } // validation - pub fn set_emojis(&mut self, val: Vec) -> Result<(), String> { - if val.len() > constants::MAX_ASC_EMOJIS { + pub fn set_emojis(&mut self, val: Vec, premium: bool) -> Result<(), String> { + let limit = if premium { + constants::MAX_PREM_ASC_EMOJIS + } else { + constants::MAX_ASC_EMOJIS + }; + + if val.len() > limit { return Err(format!( - "You can only have up to {} emojis per autostar channel.", - constants::MAX_ASC_EMOJIS + "You can only have up to {} emojis per autostar channel. The premium limit is {}.", + limit, + constants::MAX_PREM_ASC_EMOJIS, )); } diff --git a/src/database/models/guild.rs b/src/database/models/guild.rs index 88bae3a8..36b928aa 100644 --- a/src/database/models/guild.rs +++ b/src/database/models/guild.rs @@ -16,4 +16,10 @@ impl Guild { .fetch_one(pool) .await } + + pub async fn get(pool: &sqlx::PgPool, guild_id: i64) -> sqlx::Result> { + sqlx::query_as!(Self, "SELECT * FROM guilds WHERE guild_id=$1", guild_id) + .fetch_optional(pool) + .await + } } diff --git a/src/database/models/member.rs b/src/database/models/member.rs index 97f2ca92..32cbff3e 100644 --- a/src/database/models/member.rs +++ b/src/database/models/member.rs @@ -39,6 +39,24 @@ impl Member { .await } + pub async fn set_autoredeem_enabled( + pool: &sqlx::PgPool, + user_id: i64, + guild_id: i64, + autoredeem_enabled: bool, + ) -> sqlx::Result<()> { + sqlx::query!( + "UPDATE members SET autoredeem_enabled=$1 WHERE user_id=$2 AND guild_id=$3", + autoredeem_enabled, + user_id, + guild_id, + ) + .fetch_all(pool) + .await?; + + Ok(()) + } + pub async fn list_by_xp( pool: &sqlx::PgPool, guild_id: i64, @@ -90,6 +108,20 @@ impl Member { Ok(ret) } + pub async fn list_autoredeem_by_user( + pool: &sqlx::PgPool, + user_id: i64, + ) -> sqlx::Result> { + let rows = sqlx::query!( + "SELECT guild_id FROM members WHERE user_id=$1 AND autoredeem_enabled=true", + user_id + ) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|r| r.guild_id).collect()) + } + pub async fn get( pool: &sqlx::PgPool, guild_id: i64, diff --git a/src/database/models/starboard.rs b/src/database/models/starboard.rs index 983c2f84..e9d1d925 100644 --- a/src/database/models/starboard.rs +++ b/src/database/models/starboard.rs @@ -4,7 +4,6 @@ use crate::database::{ helpers::{ query::build_update::build_update, settings::starboard::call_with_starboard_settings, }, - models::starboard_settings::{settings_from_record, settings_from_row}, StarboardSettings, }; @@ -22,7 +21,9 @@ pub struct Starboard { } macro_rules! starboard_from_record { - ($record: expr) => { + ($record: expr) => {{ + use crate::database::helpers::settings::starboard::call_with_starboard_settings; + use crate::database::models::starboard_settings::settings_from_record; Starboard { id: $record.id, name: $record.name, @@ -32,11 +33,15 @@ macro_rules! starboard_from_record { premium_locked: $record.premium_locked, settings: call_with_starboard_settings!(settings_from_record, $record), } - }; + }}; } +pub(crate) use starboard_from_record; + macro_rules! starboard_from_row { - ($record: expr) => { + ($record: expr) => {{ + use crate::database::helpers::settings::starboard::call_with_starboard_settings; + use crate::database::models::starboard_settings::settings_from_row; Starboard { id: $record.get("id"), name: $record.get("name"), @@ -46,9 +51,11 @@ macro_rules! starboard_from_row { premium_locked: $record.get("premium_locked"), settings: call_with_starboard_settings!(settings_from_row, $record), } - }; + }}; } +pub(crate) use starboard_from_row; + impl Starboard { pub async fn create( pool: &sqlx::PgPool, diff --git a/src/database/models/starboard_settings.rs b/src/database/models/starboard_settings.rs index d78af1b7..df56783a 100644 --- a/src/database/models/starboard_settings.rs +++ b/src/database/models/starboard_settings.rs @@ -39,13 +39,14 @@ pub struct StarboardSettings { } macro_rules! settings_from_record { - ($has_settings: expr, $($name: ident),*) => { + ($has_settings: expr, $($name: ident),*) => {{ + use crate::database::models::starboard_settings::StarboardSettings; StarboardSettings { $( $name: $has_settings.$name, )* } - }; + }}; } macro_rules! settings_from_row { ($has_settings: expr, $($name: ident),*) => { diff --git a/src/database/validation/starboard_settings.rs b/src/database/validation/starboard_settings.rs index 2cd6bf43..bb2de5d4 100644 --- a/src/database/validation/starboard_settings.rs +++ b/src/database/validation/starboard_settings.rs @@ -76,7 +76,11 @@ pub fn validate_cooldown(capacity: i16, period: i16) -> Result<(), String> { } } -pub fn validate_vote_emojis(upvote: &[String], downvote: &[String]) -> Result<(), String> { +pub fn validate_vote_emojis( + upvote: &[String], + downvote: &[String], + premium: bool, +) -> Result<(), String> { let unique_upvote: HashSet<_> = upvote.iter().collect(); let unique_downvote: HashSet<_> = downvote.iter().collect(); @@ -90,10 +94,20 @@ pub fn validate_vote_emojis(upvote: &[String], downvote: &[String]) -> Result<() ); } - if unique_upvote.len() + unique_downvote.len() > constants::MAX_VOTE_EMOJIS { + let limit = if premium { + constants::MAX_PREM_VOTE_EMOJIS + } else { + constants::MAX_VOTE_EMOJIS + }; + + if unique_upvote.len() + unique_downvote.len() > limit { return Err(format!( - "You cannot have more than {} upvote and downvote emojis per starbard.", - constants::MAX_VOTE_EMOJIS + concat!( + "You cannot have more than {} upvote and downvote emojis per starbard. ", + "The premium limit is {}.", + ), + limit, + constants::MAX_PREM_VOTE_EMOJIS, )); } diff --git a/src/interactions/autocomplete/autoredeem.rs b/src/interactions/autocomplete/autoredeem.rs new file mode 100644 index 00000000..6d958429 --- /dev/null +++ b/src/interactions/autocomplete/autoredeem.rs @@ -0,0 +1,45 @@ +use twilight_model::application::command::{CommandOptionChoice, CommandOptionChoiceData}; + +use crate::{ + database::Member, + errors::StarboardResult, + interactions::context::CommandCtx, + utils::{id_as_i64::GetI64, into_id::IntoId}, +}; + +pub async fn autoredeem_autocomplete( + ctx: &CommandCtx, + focused: &str, +) -> StarboardResult> { + let user_id = ctx.interaction.author_id().unwrap().get_i64(); + + let guild_ids = Member::list_autoredeem_by_user(&ctx.bot.pool, user_id).await?; + + let mut arr = Vec::new(); + for guild_id in guild_ids { + let name = ctx.bot.cache.guilds.with(&guild_id.into_id(), |_, guild| { + if let Some(guild) = &guild { + if !focused.is_empty() + && !guild + .name + .to_lowercase() + .starts_with(&focused.to_lowercase()) + { + None + } else { + Some(guild.name.clone()) + } + } else { + Some(format!("Deleted Guild {guild_id}")) + } + }); + let Some(name) = name else { continue; }; + arr.push(CommandOptionChoice::String(CommandOptionChoiceData { + name: name.clone(), + name_localizations: None, + value: guild_id.to_string(), + })); + } + + Ok(arr) +} diff --git a/src/interactions/autocomplete/handle.rs b/src/interactions/autocomplete/handle.rs index 9a0b2a9d..e3594490 100644 --- a/src/interactions/autocomplete/handle.rs +++ b/src/interactions/autocomplete/handle.rs @@ -7,8 +7,8 @@ use twilight_util::builder::InteractionResponseDataBuilder; use crate::{errors::StarboardResult, interactions::context::CommandCtx}; use super::{ - autostar_name::autostar_name_autocomplete, override_name::override_name_autocomplete, - starboard_name::starboard_name_autocomplete, + autoredeem::autoredeem_autocomplete, autostar_name::autostar_name_autocomplete, + override_name::override_name_autocomplete, starboard_name::starboard_name_autocomplete, }; pub fn get_sub_options(options: &Vec) -> Option<&Vec> { @@ -23,7 +23,7 @@ pub fn get_sub_options(options: &Vec) -> Option<&Vec String { +fn parse(ctx: &CommandCtx) -> (String, &str) { let mut name = ctx.data.name.clone(); let options; @@ -44,10 +44,10 @@ pub fn qualified_name(ctx: &CommandCtx) -> String { } for option in options { - if matches!(option.value, CommandOptionValue::Focused(_, _)) { + if let CommandOptionValue::Focused(val, _) = &option.value { name.push(' '); name.push_str(&option.name); - return name; + return (name, val); } } @@ -55,12 +55,19 @@ pub fn qualified_name(ctx: &CommandCtx) -> String { } pub async fn handle_autocomplete(ctx: CommandCtx) -> StarboardResult<()> { - let options = match qualified_name(&ctx).as_str() { + let (qual_name, focused) = parse(&ctx); + let options = match qual_name.as_str() { // misc "random starboard" => starboard_name_autocomplete(&ctx).await?, "moststarred starboard" => starboard_name_autocomplete(&ctx).await?, "utils force starboard" => starboard_name_autocomplete(&ctx).await?, "utils unforce starboard" => starboard_name_autocomplete(&ctx).await?, + // premium + "premium autoredeem disable server" => autoredeem_autocomplete(&ctx, focused).await?, + "premium-locks move-autostar from" => autostar_name_autocomplete(&ctx).await?, + "premium-locks move-autostar to" => autostar_name_autocomplete(&ctx).await?, + "premium-locks move-starboard from" => starboard_name_autocomplete(&ctx).await?, + "premium-locks move-starboard to" => starboard_name_autocomplete(&ctx).await?, // autostar channels "autostar delete name" => autostar_name_autocomplete(&ctx).await?, "autostar view name" => autostar_name_autocomplete(&ctx).await?, diff --git a/src/interactions/autocomplete/mod.rs b/src/interactions/autocomplete/mod.rs index 7fa24dbe..9fbc5cd9 100644 --- a/src/interactions/autocomplete/mod.rs +++ b/src/interactions/autocomplete/mod.rs @@ -1,3 +1,4 @@ +mod autoredeem; mod autostar_name; pub mod handle; mod override_name; diff --git a/src/interactions/commands/chat/autostar/create.rs b/src/interactions/commands/chat/autostar/create.rs index 37f0b9dc..ae3130a7 100644 --- a/src/interactions/commands/chat/autostar/create.rs +++ b/src/interactions/commands/chat/autostar/create.rs @@ -3,6 +3,7 @@ use twilight_model::application::interaction::application_command::InteractionCh use crate::{ constants, + core::premium::is_premium::is_guild_premium, database::{validation, AutoStarChannel, Guild}, errors::StarboardResult, get_guild_id, @@ -31,9 +32,8 @@ pub struct CreateAutoStarChannel { impl CreateAutoStarChannel { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { - let guild_id = get_guild_id!(ctx); - let guild_id_i64 = guild_id.get_i64(); - map_dup_none!(Guild::create(&ctx.bot.pool, guild_id_i64))?; + let guild_id = get_guild_id!(ctx).get_i64(); + map_dup_none!(Guild::create(&ctx.bot.pool, guild_id))?; let channel_id = self.channel.id.get_i64(); let name = match validation::name::validate_name(&self.name) { @@ -44,12 +44,18 @@ impl CreateAutoStarChannel { Ok(name) => name, }; - let count = AutoStarChannel::count_by_guild(&ctx.bot.pool, guild_id_i64).await?; - if count >= constants::MAX_AUTOSTAR { + let count = AutoStarChannel::count_by_guild(&ctx.bot.pool, guild_id).await?; + let limit = if is_guild_premium(&ctx.bot, guild_id).await? { + constants::MAX_PREM_AUTOSTAR + } else { + constants::MAX_AUTOSTAR + }; + if count >= limit { ctx.respond_str( &format!( - "You can only have up to {} autostar channels.", - constants::MAX_AUTOSTAR + "You can only have up to {} autostar channels. The premium limit is {}.", + limit, + constants::MAX_PREM_AUTOSTAR, ), true, ) @@ -61,7 +67,7 @@ impl CreateAutoStarChannel { &ctx.bot.pool, &name, channel_id, - guild_id_i64, + guild_id, ))?; if ret.is_none() { diff --git a/src/interactions/commands/chat/autostar/edit.rs b/src/interactions/commands/chat/autostar/edit.rs index 365ab06d..9671433f 100644 --- a/src/interactions/commands/chat/autostar/edit.rs +++ b/src/interactions/commands/chat/autostar/edit.rs @@ -1,7 +1,10 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ - core::emoji::{EmojiCommon, SimpleEmoji}, + core::{ + emoji::{EmojiCommon, SimpleEmoji}, + premium::is_premium::is_guild_premium, + }, database::AutoStarChannel, errors::StarboardResult, get_guild_id, @@ -34,9 +37,9 @@ pub struct EditAutoStar { impl EditAutoStar { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx); + let guild_id_i64 = guild_id.get_i64(); - let asc = - AutoStarChannel::get_by_name(&ctx.bot.pool, &self.name, guild_id.get_i64()).await?; + let asc = AutoStarChannel::get_by_name(&ctx.bot.pool, &self.name, guild_id_i64).await?; let mut asc = match asc { None => { ctx.respond_str("No autostar channel with that name was found.", true) @@ -46,9 +49,11 @@ impl EditAutoStar { Some(asc) => asc, }; + let is_prem = is_guild_premium(&ctx.bot, guild_id_i64).await?; + if let Some(val) = self.emojis { let emojis = Vec::::from_user_input(val, &ctx.bot, guild_id).into_stored(); - if let Err(why) = asc.set_emojis(emojis) { + if let Err(why) = asc.set_emojis(emojis, is_prem) { ctx.respond_str(&why, true).await?; return Ok(()); } diff --git a/src/interactions/commands/chat/autostar/view.rs b/src/interactions/commands/chat/autostar/view.rs index e59790af..5a049f23 100644 --- a/src/interactions/commands/chat/autostar/view.rs +++ b/src/interactions/commands/chat/autostar/view.rs @@ -35,7 +35,18 @@ impl ViewAutoStarChannels { .max_chars .map(|v| v.to_string()) .unwrap_or_else(|| "none".to_string()); + + let note = if asc.premium_locked { + concat!( + "This autostar channel is locked because it exceeds the non-premium ", + "limit.\n\n" + ) + } else { + "" + }; + let asc_settings = concat_format!( + "{}" <- note; "This autostar channel is in <#{}>.\n\n" <- asc.channel_id; "emojis: {}\n" <- emojis; "min-chars: {}\n" <- asc.min_chars; @@ -67,14 +78,18 @@ impl ViewAutoStarChannels { let mut desc = String::new(); for a in asc.into_iter() { - writeln!( + write!( desc, "'{}' in <#{}>: {}", a.name, a.channel_id, - Vec::::from_stored(a.emojis).into_readable(&ctx.bot, guild_id) + Vec::::from_stored(a.emojis).into_readable(&ctx.bot, guild_id), ) .unwrap(); + if a.premium_locked { + write!(desc, " (premium-locked)").unwrap(); + } + writeln!(desc).unwrap(); } let emb = embed::build() .title("Autostar Channels") diff --git a/src/interactions/commands/chat/mod.rs b/src/interactions/commands/chat/mod.rs index dd310abb..d87db8c0 100644 --- a/src/interactions/commands/chat/mod.rs +++ b/src/interactions/commands/chat/mod.rs @@ -6,6 +6,8 @@ pub mod overrides; pub mod permroles; pub mod ping; pub mod posroles; +pub mod premium; +pub mod premium_locks; pub mod random; pub mod starboard; pub mod stats; diff --git a/src/interactions/commands/chat/overrides/edit/requirements.rs b/src/interactions/commands/chat/overrides/edit/requirements.rs index bae01e57..2671b2bb 100644 --- a/src/interactions/commands/chat/overrides/edit/requirements.rs +++ b/src/interactions/commands/chat/overrides/edit/requirements.rs @@ -3,6 +3,7 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ core::{ emoji::{EmojiCommon, SimpleEmoji}, + premium::is_premium::is_guild_premium, starboard::config::StarboardConfig, }, database::{ @@ -76,6 +77,8 @@ impl EditRequirements { }; let mut settings = ov.get_overrides()?; + let is_prem = is_guild_premium(&ctx.bot, guild_id_i64).await?; + if let Some(val) = self.required { let val = val as i16; if let Err(why) = @@ -120,6 +123,7 @@ impl EditRequirements { .downvote_emojis .as_ref() .unwrap_or(&resolved.downvote_emojis), + is_prem, ) { ctx.respond_str(&why, true).await?; return Ok(()); diff --git a/src/interactions/commands/chat/posroles/delete.rs b/src/interactions/commands/chat/posroles/delete.rs index ed62cc88..f781b9f5 100644 --- a/src/interactions/commands/chat/posroles/delete.rs +++ b/src/interactions/commands/chat/posroles/delete.rs @@ -3,6 +3,7 @@ use twilight_model::guild::Role; use crate::{ concat_format, + core::premium::is_premium::is_guild_premium, database::PosRole, errors::StarboardResult, get_guild_id, @@ -43,6 +44,12 @@ impl ClearDeleted { let guild_id = get_guild_id!(ctx); let guild_id_i64 = guild_id.get_i64(); + if !is_guild_premium(&ctx.bot, guild_id_i64).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + let pr = PosRole::list_by_guild(&ctx.bot.pool, guild_id_i64).await?; let (to_delete_pretty, to_delete) = get_deleted_roles( diff --git a/src/interactions/commands/chat/posroles/refresh.rs b/src/interactions/commands/chat/posroles/refresh.rs index 5ff687d0..7bff2d85 100644 --- a/src/interactions/commands/chat/posroles/refresh.rs +++ b/src/interactions/commands/chat/posroles/refresh.rs @@ -1,8 +1,12 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ - concat_format, core::posroles::update_posroles_for_guild, errors::StarboardResult, - get_guild_id, interactions::context::CommandCtx, + concat_format, + core::{posroles::update_posroles_for_guild, premium::is_premium::is_guild_premium}, + errors::StarboardResult, + get_guild_id, + interactions::context::CommandCtx, + utils::id_as_i64::GetI64, }; #[derive(CommandModel, CreateCommand)] @@ -13,6 +17,12 @@ impl Refresh { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx); + if !is_guild_premium(&ctx.bot, guild_id.get_i64()).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + ctx.defer(true).await?; let ret = update_posroles_for_guild(ctx.bot.clone(), guild_id).await?; diff --git a/src/interactions/commands/chat/posroles/set_max_members.rs b/src/interactions/commands/chat/posroles/set_max_members.rs index d08ad4e0..4d187b39 100644 --- a/src/interactions/commands/chat/posroles/set_max_members.rs +++ b/src/interactions/commands/chat/posroles/set_max_members.rs @@ -2,8 +2,9 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use twilight_model::guild::Role; use crate::{ - constants, database::PosRole, errors::StarboardResult, get_guild_id, - interactions::context::CommandCtx, map_dup_none, utils::id_as_i64::GetI64, + constants, core::premium::is_premium::is_guild_premium, database::PosRole, + errors::StarboardResult, get_guild_id, interactions::context::CommandCtx, map_dup_none, + utils::id_as_i64::GetI64, }; #[derive(CommandModel, CreateCommand)] @@ -23,6 +24,12 @@ impl SetMaxMembers { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx).get_i64(); + if !is_guild_premium(&ctx.bot, guild_id).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + if self.role.id.get_i64() == guild_id || self.role.managed { ctx.respond_str("You can't use that role for award roles.", true) .await?; diff --git a/src/interactions/commands/chat/posroles/view.rs b/src/interactions/commands/chat/posroles/view.rs index bf43c280..2e6849ff 100644 --- a/src/interactions/commands/chat/posroles/view.rs +++ b/src/interactions/commands/chat/posroles/view.rs @@ -4,6 +4,7 @@ use thousands::Separable; use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ + core::premium::is_premium::is_guild_premium, database::PosRole, errors::StarboardResult, get_guild_id, @@ -19,6 +20,12 @@ impl View { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx).get_i64(); + if !is_guild_premium(&ctx.bot, guild_id).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + let posroles = PosRole::list_by_guild(&ctx.bot.pool, guild_id).await?; if posroles.is_empty() { ctx.respond_str("There are no PosRoles.", true).await?; diff --git a/src/interactions/commands/chat/premium/autoredeem/disable.rs b/src/interactions/commands/chat/premium/autoredeem/disable.rs new file mode 100644 index 00000000..677298a6 --- /dev/null +++ b/src/interactions/commands/chat/premium/autoredeem/disable.rs @@ -0,0 +1,50 @@ +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + database::Member, errors::StarboardResult, interactions::context::CommandCtx, + utils::id_as_i64::GetI64, +}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "disable", desc = "Disable autoredeem for a server.")] +pub struct Disable { + /// The server to disable autoredeem for. + #[command(autocomplete = true)] + server: Option, +} + +impl Disable { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let user_id = ctx.interaction.author_id().unwrap().get_i64(); + + let guild_id = 'out: { + let Some(input_guild) = self.server else { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str( + "Please specify a server, or run this command inside one.", + true + ).await?; + return Ok(()); + }; + + break 'out guild_id.get_i64(); + }; + + let Ok(guild_id) = input_guild.parse::() else { + ctx.respond_str( + "Please entire a server ID, or select a server from the options.", + true + ).await?; + return Ok(()); + }; + + guild_id + }; + + Member::set_autoredeem_enabled(&ctx.bot.pool, user_id, guild_id, false).await?; + + ctx.respond_str("Autoredeem disabled.", true).await?; + + Ok(()) + } +} diff --git a/src/interactions/commands/chat/premium/autoredeem/enable.rs b/src/interactions/commands/chat/premium/autoredeem/enable.rs new file mode 100644 index 00000000..31395420 --- /dev/null +++ b/src/interactions/commands/chat/premium/autoredeem/enable.rs @@ -0,0 +1,32 @@ +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + database::Member, errors::StarboardResult, interactions::context::CommandCtx, map_dup_none, + utils::id_as_i64::GetI64, +}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "enable", desc = "Enable autoredeem for the current server.")] +pub struct Enable; + +impl Enable { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str( + "Please run this command inside a server.", + true + ).await?; + return Ok(()); + }; + let guild_id = guild_id.get_i64(); + let user_id = ctx.interaction.author_id().unwrap().get_i64(); + + map_dup_none!(Member::create(&ctx.bot.pool, user_id, guild_id))?; + + Member::set_autoredeem_enabled(&ctx.bot.pool, user_id, guild_id, true).await?; + + ctx.respond_str("Autoredeem enabled.", true).await?; + + Ok(()) + } +} diff --git a/src/interactions/commands/chat/premium/autoredeem/mod.rs b/src/interactions/commands/chat/premium/autoredeem/mod.rs new file mode 100644 index 00000000..73381229 --- /dev/null +++ b/src/interactions/commands/chat/premium/autoredeem/mod.rs @@ -0,0 +1,24 @@ +mod disable; +mod enable; + +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{errors::StarboardResult, interactions::context::CommandCtx}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "autoredeem", desc = "Manage autoredeem.")] +pub enum Autoredeem { + #[command(name = "disable")] + Disable(disable::Disable), + #[command(name = "enable")] + Enable(enable::Enable), +} + +impl Autoredeem { + pub async fn callback(self, ctx: CommandCtx) -> StarboardResult<()> { + match self { + Self::Disable(cmd) => cmd.callback(ctx).await, + Self::Enable(cmd) => cmd.callback(ctx).await, + } + } +} diff --git a/src/interactions/commands/chat/premium/info.rs b/src/interactions/commands/chat/premium/info.rs new file mode 100644 index 00000000..1ec80e90 --- /dev/null +++ b/src/interactions/commands/chat/premium/info.rs @@ -0,0 +1,98 @@ +use std::{borrow::Cow, fmt::Write}; + +use twilight_interactions::command::{CommandModel, CreateCommand}; +use twilight_model::channel::message::MessageFlags; +use twilight_util::builder::embed::EmbedFieldBuilder; + +use crate::{ + concat_format, constants, + database::{Guild, Member, User}, + errors::StarboardResult, + interactions::context::CommandCtx, + utils::{embed, id_as_i64::GetI64, into_id::IntoId}, +}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "info", desc = "Get premium info.")] +pub struct Info; + +impl Info { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let user_id = ctx.interaction.author_id().unwrap().get_i64(); + + let user = User::get(&ctx.bot.pool, user_id).await?; + let credits = match &user { + Some(user) => user.credits, + None => 0, + }; + + let mut emb = embed::build() + .title("Starboard Premium") + .description(concat_format!( + "Starboard uses a credit system for premium. For each USD you donate (currently "; + "only Patreon is supported), you receive one premium credit. Three premium "; + "credits can be redeemed for one month of premium in any server of your choice."; + "\n\nThis means that premium for one server is $3/month, two servers is $6/month, "; + "and so on. To get premium, visit my [Patreon]({})." <- constants::PATREON_URL; + )) + .field(EmbedFieldBuilder::new( + "Status", + format!("You currently have {credits} credits."), + )); + + 'out: { + if user.is_none() { + break 'out; + } + + let ar = Member::list_autoredeem_by_user(&ctx.bot.pool, user_id).await?; + if ar.is_empty() { + break 'out; + } + + let mut value = "Autoredeem is enabled for the following servers:\n".to_string(); + for guild_id in ar { + ctx.bot.cache.guilds.with(&guild_id.into_id(), |_, guild| { + if let Some(guild) = &guild { + value.push_str(&guild.name); + value.push('\n'); + } else { + writeln!(value, "Deleted Guild {guild_id}").unwrap(); + } + }); + } + + value.push_str(concat!( + "\nAutoredeem will automatically take credits from your account when the server ", + "runs out of premium. This will only occur if Starboard is still in that server ", + "and you are still in that server.\n\n Disable it at any time by using ", + "`/premium autoredeem disable`." + )); + + emb = emb.field(EmbedFieldBuilder::new("Autoredeem", value)); + } + + if let Some(guild_id) = ctx.interaction.guild_id { + let guild = Guild::get(&ctx.bot.pool, guild_id.get_i64()).await?; + + let value = match guild.and_then(|g| g.premium_end) { + None => Cow::Borrowed("This server does not have premium."), + Some(end) => Cow::Owned(format!( + "This server has premium until .", + end.timestamp() + )), + }; + emb = emb.field(EmbedFieldBuilder::new("Server Premium", value)); + }; + + ctx.respond( + ctx.build_resp() + .embeds([emb.build()]) + .flags(MessageFlags::EPHEMERAL) + .build(), + ) + .await?; + + Ok(()) + } +} diff --git a/src/interactions/commands/chat/premium/mod.rs b/src/interactions/commands/chat/premium/mod.rs new file mode 100644 index 00000000..10ed5900 --- /dev/null +++ b/src/interactions/commands/chat/premium/mod.rs @@ -0,0 +1,31 @@ +mod autoredeem; +mod info; +mod redeem; + +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{errors::StarboardResult, interactions::context::CommandCtx}; + +#[derive(CommandModel, CreateCommand)] +#[command( + name = "premium", + desc = "Premium-releated commands. See /premium-locks for locks." +)] +pub enum Premium { + #[command(name = "info")] + Info(info::Info), + #[command(name = "autoredeem")] + Autoredeem(autoredeem::Autoredeem), + #[command(name = "redeem")] + Redeem(redeem::Redeem), +} + +impl Premium { + pub async fn callback(self, ctx: CommandCtx) -> StarboardResult<()> { + match self { + Self::Info(cmd) => cmd.callback(ctx).await, + Self::Autoredeem(cmd) => cmd.callback(ctx).await, + Self::Redeem(cmd) => cmd.callback(ctx).await, + } + } +} diff --git a/src/interactions/commands/chat/premium/redeem.rs b/src/interactions/commands/chat/premium/redeem.rs new file mode 100644 index 00000000..7d0f8470 --- /dev/null +++ b/src/interactions/commands/chat/premium/redeem.rs @@ -0,0 +1,86 @@ +use std::borrow::Cow; + +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + constants, + core::premium::redeem::{redeem_premium, RedeemPremiumResult}, + database::Guild, + errors::StarboardResult, + interactions::context::CommandCtx, + map_dup_none, + utils::{id_as_i64::GetI64, views::confirm}, +}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "redeem", desc = "Redeem your premium credits.")] +pub struct Redeem { + /// The number of months of premium to redeem. Each month is three credits. + #[command(min_value = 1, max_value = 6)] + months: i64, +} + +impl Redeem { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str("Please run this command in the server you want premium for.", true).await?; + return Ok(()); + }; + let guild_id_i64 = guild_id.get_i64(); + let user_id = ctx.interaction.author_id().unwrap().get_i64(); + + let guild = map_dup_none!(Guild::create(&ctx.bot.pool, guild_id_i64))?; + let guild = match guild { + Some(guild) => guild, + None => Guild::get(&ctx.bot.pool, guild_id_i64).await?.unwrap(), + }; + + let end_pretty = if let Some(end) = guild.premium_end { + Cow::Owned(format!( + "This server has premium until .", + end.timestamp() + )) + } else { + Cow::Borrowed("This server does not have premium.") + }; + + let ret = confirm::simple( + &mut ctx, + &format!( + concat!( + "{} Doing this will will add {} month(s) of premium (each \"month\" is 31 ", + "days), and cost you {} credits. Do you wish to continue?" + ), + end_pretty, + self.months, + self.months * constants::CREDITS_PER_MONTH as i64 + ), + false, + ) + .await?; + let Some(mut btn_ctx) = ret else { + return Ok(()); + }; + + let ret = redeem_premium( + &ctx.bot, + user_id, + guild_id_i64, + self.months as u64, + Some(guild.premium_end), + ) + .await?; + + let resp = match ret { + RedeemPremiumResult::Ok => "Done.", + RedeemPremiumResult::StateMismatch => concat!( + "This server's premium status changed while you were running the command. ", + "Please try again." + ), + RedeemPremiumResult::TooFewCredits => "You don't have enough credits.", + }; + btn_ctx.edit_str(resp, true).await?; + + Ok(()) + } +} diff --git a/src/interactions/commands/chat/premium_locks/mod.rs b/src/interactions/commands/chat/premium_locks/mod.rs new file mode 100644 index 00000000..6b453fc9 --- /dev/null +++ b/src/interactions/commands/chat/premium_locks/mod.rs @@ -0,0 +1,35 @@ +mod move_autostar; +mod move_starboard; +mod refresh; + +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + errors::StarboardResult, + interactions::{commands::permissions::manage_channels, context::CommandCtx}, +}; + +#[derive(CommandModel, CreateCommand)] +#[command( + name = "premium-locks", + desc = "Manage premium locks.", + default_permissions = "manage_channels" +)] +pub enum PremiumLocks { + #[command(name = "refresh")] + Refresh(refresh::Refresh), + #[command(name = "move-autostar")] + MoveAutostar(move_autostar::MoveAutostar), + #[command(name = "move-starboard")] + MoveStarboard(move_starboard::MoveStarboard), +} + +impl PremiumLocks { + pub async fn callback(self, ctx: CommandCtx) -> StarboardResult<()> { + match self { + Self::Refresh(cmd) => cmd.callback(ctx).await, + Self::MoveAutostar(cmd) => cmd.callback(ctx).await, + Self::MoveStarboard(cmd) => cmd.callback(ctx).await, + } + } +} diff --git a/src/interactions/commands/chat/premium_locks/move_autostar.rs b/src/interactions/commands/chat/premium_locks/move_autostar.rs new file mode 100644 index 00000000..f113793b --- /dev/null +++ b/src/interactions/commands/chat/premium_locks/move_autostar.rs @@ -0,0 +1,97 @@ +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + database::AutoStarChannel, errors::StarboardResult, interactions::context::CommandCtx, + utils::id_as_i64::GetI64, +}; + +#[derive(CommandModel, CreateCommand)] +#[command( + name = "move-autostar", + desc = "Move a lock from one autostar channel to another." +)] +pub struct MoveAutostar { + /// The autostar channel to move the lock from. + #[command(rename = "from", autocomplete = true)] + autostar_from: String, + /// The autostar channel to move the lock to. + #[command(rename = "to", autocomplete = true)] + autostar_to: String, +} + +impl MoveAutostar { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str("Please run this command inside a server.", true).await?; + return Ok(()); + }; + let guild_id_i64 = guild_id.get_i64(); + + let mut tx = ctx.bot.pool.begin().await?; + + let Some(asc_from) = get_for_update(&mut ctx, &mut tx, guild_id_i64, &self.autostar_from).await? else { + return Ok(()); + }; + let Some(asc_to) = get_for_update(&mut ctx, &mut tx, guild_id_i64, &self.autostar_to).await? else { + return Ok(()); + }; + + if !asc_from.premium_locked { + ctx.respond_str( + &format!("Autostar channel '{}' is not locked.", asc_from.name), + true, + ) + .await?; + return Ok(()); + } + if asc_to.premium_locked { + ctx.respond_str( + &format!("Autostar channel '{}' is already locked.", asc_to.name), + true, + ) + .await?; + return Ok(()); + } + + sqlx::query!( + "UPDATE autostar_channels SET premium_locked=true WHERE id=$1", + asc_to.id, + ) + .fetch_all(&mut tx) + .await?; + sqlx::query!( + "UPDATE autostar_channels SET premium_locked=false WHERE id=$1", + asc_from.id, + ) + .fetch_all(&mut tx) + .await?; + + tx.commit().await?; + + ctx.respond_str("Done.", true).await?; + + Ok(()) + } +} + +async fn get_for_update( + ctx: &mut CommandCtx, + con: &mut sqlx::Transaction<'_, sqlx::Postgres>, + guild_id: i64, + name: &str, +) -> StarboardResult> { + let asc = sqlx::query_as!( + AutoStarChannel, + "SELECT * FROM autostar_channels WHERE guild_id=$1 AND name=$2 FOR UPDATE", + guild_id, + name, + ) + .fetch_optional(con) + .await?; + let Some(asc) = asc else { + ctx.respond_str(&format!("Autotstar channel '{name}' does not exist."), true).await?; + return Ok(None); + }; + + Ok(Some(asc)) +} diff --git a/src/interactions/commands/chat/premium_locks/move_starboard.rs b/src/interactions/commands/chat/premium_locks/move_starboard.rs new file mode 100644 index 00000000..68bc4f30 --- /dev/null +++ b/src/interactions/commands/chat/premium_locks/move_starboard.rs @@ -0,0 +1,99 @@ +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + database::{models::starboard::starboard_from_record, Starboard}, + errors::StarboardResult, + interactions::context::CommandCtx, + utils::id_as_i64::GetI64, +}; + +#[derive(CommandModel, CreateCommand)] +#[command( + name = "move-starboard", + desc = "Move a lock from one starboard to another." +)] +pub struct MoveStarboard { + /// The starboard to move the lock from. + #[command(rename = "from", autocomplete = true)] + starboard_from: String, + /// The starboard to move the lock to. + #[command(rename = "to", autocomplete = true)] + starboard_to: String, +} + +impl MoveStarboard { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str("Please run this command inside a server.", true).await?; + return Ok(()); + }; + let guild_id_i64 = guild_id.get_i64(); + + let mut tx = ctx.bot.pool.begin().await?; + + let Some(sb_from) = get_for_update(&mut ctx, &mut tx, guild_id_i64, &self.starboard_from).await? else { + return Ok(()); + }; + let Some(sb_to) = get_for_update(&mut ctx, &mut tx, guild_id_i64, &self.starboard_to).await? else { + return Ok(()); + }; + + if !sb_from.premium_locked { + ctx.respond_str( + &format!("Starboard '{}' is not locked.", sb_from.name), + true, + ) + .await?; + return Ok(()); + } + if sb_to.premium_locked { + ctx.respond_str( + &format!("Starboard '{}' is already locked.", sb_to.name), + true, + ) + .await?; + return Ok(()); + } + + sqlx::query!( + "UPDATE starboards SET premium_locked=true WHERE id=$1", + sb_to.id, + ) + .fetch_all(&mut tx) + .await?; + sqlx::query!( + "UPDATE starboards SET premium_locked=false WHERE id=$1", + sb_from.id, + ) + .fetch_all(&mut tx) + .await?; + + tx.commit().await?; + + ctx.respond_str("Done.", true).await?; + + Ok(()) + } +} + +async fn get_for_update( + ctx: &mut CommandCtx, + con: &mut sqlx::Transaction<'_, sqlx::Postgres>, + guild_id: i64, + name: &str, +) -> StarboardResult> { + let sb = sqlx::query!( + "SELECT * FROM starboards WHERE guild_id=$1 AND name=$2 FOR UPDATE", + guild_id, + name, + ) + .fetch_optional(con) + .await?; + + let Some(sb) = sb else { + ctx.respond_str(&format!("Starboard '{name}' does not exist."), true).await?; + return Ok(None); + }; + + Ok(Some(starboard_from_record!(sb))) +} diff --git a/src/interactions/commands/chat/premium_locks/refresh.rs b/src/interactions/commands/chat/premium_locks/refresh.rs new file mode 100644 index 00000000..1a69e435 --- /dev/null +++ b/src/interactions/commands/chat/premium_locks/refresh.rs @@ -0,0 +1,33 @@ +use twilight_interactions::command::{CommandModel, CreateCommand}; + +use crate::{ + core::premium::{is_premium::is_guild_premium, locks::refresh_premium_locks}, + errors::StarboardResult, + interactions::context::CommandCtx, + utils::id_as_i64::GetI64, +}; + +#[derive(CommandModel, CreateCommand)] +#[command(name = "refresh", desc = "Refreshes premium locks.")] +pub struct Refresh; + +impl Refresh { + pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { + let Some(guild_id) = ctx.interaction.guild_id else { + ctx.respond_str("Please run this command inside a server.", true).await?; + return Ok(()); + }; + let guild_id = guild_id.get_i64(); + + refresh_premium_locks( + &ctx.bot, + guild_id, + is_guild_premium(&ctx.bot, guild_id).await?, + ) + .await?; + + ctx.respond_str("Refreshed locks.", true).await?; + + Ok(()) + } +} diff --git a/src/interactions/commands/chat/starboard/create.rs b/src/interactions/commands/chat/starboard/create.rs index c903456f..0443a17d 100644 --- a/src/interactions/commands/chat/starboard/create.rs +++ b/src/interactions/commands/chat/starboard/create.rs @@ -3,6 +3,7 @@ use twilight_model::application::interaction::application_command::InteractionCh use crate::{ constants, + core::premium::is_premium::is_guild_premium, database::{validation, Guild, Starboard}, errors::StarboardResult, get_guild_id, @@ -31,17 +32,22 @@ pub struct CreateStarboard { impl CreateStarboard { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { - let guild_id = get_guild_id!(ctx); - let guild_id_i64 = guild_id.get_i64(); - map_dup_none!(Guild::create(&ctx.bot.pool, guild_id_i64))?; + let guild_id = get_guild_id!(ctx).get_i64(); + map_dup_none!(Guild::create(&ctx.bot.pool, guild_id))?; let channel_id = self.channel.id.get_i64(); - let count = Starboard::count_by_guild(&ctx.bot.pool, guild_id_i64).await?; - if count >= constants::MAX_STARBOARDS { + let count = Starboard::count_by_guild(&ctx.bot.pool, guild_id).await?; + let limit = if is_guild_premium(&ctx.bot, guild_id).await? { + constants::MAX_PREM_STARBOARDS + } else { + constants::MAX_STARBOARDS + }; + if count >= limit { ctx.respond_str( &format!( - "You can only have up to {} starboards.", - constants::MAX_STARBOARDS + "You can only have up to {} starboards. The premium limit is {}.", + limit, + constants::MAX_PREM_STARBOARDS, ), true, ) @@ -61,7 +67,7 @@ impl CreateStarboard { &ctx.bot.pool, &name, channel_id, - guild_id.get_i64(), + guild_id, ))?; if ret.is_none() { @@ -71,7 +77,7 @@ impl CreateStarboard { ) .await?; } else { - ctx.bot.cache.guild_vote_emojis.remove(&guild_id_i64); + ctx.bot.cache.guild_vote_emojis.remove(&guild_id); ctx.respond_str( &format!("Created starboard '{name}' in <#{channel_id}>."), diff --git a/src/interactions/commands/chat/starboard/edit/requirements.rs b/src/interactions/commands/chat/starboard/edit/requirements.rs index dd6ef118..3d17a2e8 100644 --- a/src/interactions/commands/chat/starboard/edit/requirements.rs +++ b/src/interactions/commands/chat/starboard/edit/requirements.rs @@ -1,7 +1,10 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ - core::emoji::{EmojiCommon, SimpleEmoji}, + core::{ + emoji::{EmojiCommon, SimpleEmoji}, + premium::is_premium::is_guild_premium, + }, database::{ validation::{self, time_delta::parse_time_delta}, Starboard, @@ -65,6 +68,8 @@ impl EditRequirements { Some(starboard) => starboard, }; + let is_prem = is_guild_premium(&ctx.bot, guild_id_i64).await?; + if let Some(val) = self.required { let val = val as i16; if let Err(why) = validation::starboard_settings::validate_required( @@ -105,6 +110,7 @@ impl EditRequirements { if let Err(why) = validation::starboard_settings::validate_vote_emojis( &starboard.settings.upvote_emojis, &starboard.settings.downvote_emojis, + is_prem, ) { ctx.respond_str(&why, true).await?; return Ok(()); diff --git a/src/interactions/commands/chat/starboard/view.rs b/src/interactions/commands/chat/starboard/view.rs index 5c49acee..455f6f6f 100644 --- a/src/interactions/commands/chat/starboard/view.rs +++ b/src/interactions/commands/chat/starboard/view.rs @@ -32,12 +32,22 @@ impl ViewStarboard { let config = StarboardConfig::new(starboard, vec![])?; let pretty = format_settings(&ctx.bot, guild_id, &config); + let mut desc = String::new(); + if config.starboard.premium_locked { + desc.push_str(concat!( + "This starboard is locked because it exceeds the non-premium limit.\n\n" + )); + } + write!( + desc, + "This starboard is in <#{}>.", + config.starboard.channel_id + ) + .unwrap(); + let embed = embed::build() .title(format!("Starboard '{}'", &config.starboard.name)) - .description(format!( - "This starboard is in <#{}>.", - config.starboard.channel_id - )) + .description(desc) .field( EmbedFieldBuilder::new("Requirements", pretty.requirements) .inline() @@ -75,7 +85,11 @@ impl ViewStarboard { let mut final_result = String::new(); for sb in starboards { - writeln!(final_result, "'{}' in <#{}>", sb.name, sb.channel_id).unwrap(); + write!(final_result, "'{}' in <#{}>", sb.name, sb.channel_id).unwrap(); + if sb.premium_locked { + write!(final_result, " (premium-locked)").unwrap(); + } + writeln!(final_result).unwrap(); } let embed = embed::build() diff --git a/src/interactions/commands/chat/xproles/delete.rs b/src/interactions/commands/chat/xproles/delete.rs index dee0f279..1f68aa64 100644 --- a/src/interactions/commands/chat/xproles/delete.rs +++ b/src/interactions/commands/chat/xproles/delete.rs @@ -3,6 +3,7 @@ use twilight_model::guild::Role; use crate::{ concat_format, + core::premium::is_premium::is_guild_premium, database::XPRole, errors::StarboardResult, get_guild_id, @@ -43,6 +44,12 @@ impl ClearDeleted { let guild_id = get_guild_id!(ctx); let guild_id_i64 = guild_id.get_i64(); + if !is_guild_premium(&ctx.bot, guild_id_i64).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + let xpr = XPRole::list_by_guild(&ctx.bot.pool, guild_id_i64).await?; let (to_delete_pretty, to_delete) = get_deleted_roles( diff --git a/src/interactions/commands/chat/xproles/setxp.rs b/src/interactions/commands/chat/xproles/setxp.rs index 0376900a..fcd6ae71 100644 --- a/src/interactions/commands/chat/xproles/setxp.rs +++ b/src/interactions/commands/chat/xproles/setxp.rs @@ -2,8 +2,9 @@ use twilight_interactions::command::{CommandModel, CreateCommand}; use twilight_model::guild::Role; use crate::{ - constants, database::XPRole, errors::StarboardResult, get_guild_id, - interactions::context::CommandCtx, map_dup_none, utils::id_as_i64::GetI64, + constants, core::premium::is_premium::is_guild_premium, database::XPRole, + errors::StarboardResult, get_guild_id, interactions::context::CommandCtx, map_dup_none, + utils::id_as_i64::GetI64, }; #[derive(CommandModel, CreateCommand)] @@ -20,6 +21,12 @@ impl SetXP { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx).get_i64(); + if !is_guild_premium(&ctx.bot, guild_id).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + if self.role.id.get_i64() == guild_id || self.role.managed { ctx.respond_str("You can't use that role for award roles.", true) .await?; diff --git a/src/interactions/commands/chat/xproles/view.rs b/src/interactions/commands/chat/xproles/view.rs index 71a5ac6f..daa809f9 100644 --- a/src/interactions/commands/chat/xproles/view.rs +++ b/src/interactions/commands/chat/xproles/view.rs @@ -4,6 +4,7 @@ use thousands::Separable; use twilight_interactions::command::{CommandModel, CreateCommand}; use crate::{ + core::premium::is_premium::is_guild_premium, database::XPRole, errors::StarboardResult, get_guild_id, @@ -19,6 +20,12 @@ impl View { pub async fn callback(self, mut ctx: CommandCtx) -> StarboardResult<()> { let guild_id = get_guild_id!(ctx).get_i64(); + if !is_guild_premium(&ctx.bot, guild_id).await? { + ctx.respond_str("Only premium servers can use this command.", true) + .await?; + return Ok(()); + } + let xproles = XPRole::list_by_guild(&ctx.bot.pool, guild_id).await?; if xproles.is_empty() { ctx.respond_str("There are no XPRoles.", true).await?; diff --git a/src/interactions/commands/handle.rs b/src/interactions/commands/handle.rs index 9503f300..7f562b4a 100644 --- a/src/interactions/commands/handle.rs +++ b/src/interactions/commands/handle.rs @@ -33,6 +33,8 @@ pub async fn handle_command(ctx: CommandCtx) -> StarboardResult<()> { "xproles" => chat::xproles::XPRoles, "posroles" => chat::posroles::PosRoles, "utils" => chat::utils::Utils, + "premium" => chat::premium::Premium, + "premium-locks" => chat::premium_locks::PremiumLocks, ); Ok(()) diff --git a/src/interactions/commands/register.rs b/src/interactions/commands/register.rs index 40ab69f2..ad8ec63c 100644 --- a/src/interactions/commands/register.rs +++ b/src/interactions/commands/register.rs @@ -31,6 +31,8 @@ pub async fn post_commands(bot: Arc) { chat::xproles::XPRoles, chat::posroles::PosRoles, chat::utils::Utils, + chat::premium::Premium, + chat::premium_locks::PremiumLocks, ); match inter_client.set_global_commands(&commands).await {