diff --git a/Cargo.lock b/Cargo.lock index d3b6e815..83ac3f8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2016,6 +2016,7 @@ dependencies = [ name = "refraction" version = "2.0.0" dependencies = [ + "chrono", "color-eyre", "dotenvy", "enum_dispatch", diff --git a/Cargo.toml b/Cargo.toml index ccacd507..4dca0b03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ tokio = { version = "1.37.0", features = [ "signal", ] } rustls = "0.23.13" +chrono = "0.4.43" [lints.rust] unsafe_code = "forbid" diff --git a/src/handlers/event/mod.rs b/src/handlers/event/mod.rs index 017ccb76..5e09be21 100644 --- a/src/handlers/event/mod.rs +++ b/src/handlers/event/mod.rs @@ -9,6 +9,7 @@ mod eta; mod expand_link; mod give_role; mod pluralkit; +mod regular_role; mod support_onboard; pub async fn handle( @@ -35,6 +36,12 @@ pub async fn handle( } } + FullEvent::GuildMemberAddition { new_member } => { + if let Some(storage) = &data.storage { + regular_role::handle_member(ctx, storage, new_member).await?; + } + } + FullEvent::Message { new_message } => { trace!("Received message {}", new_message.content); @@ -58,6 +65,8 @@ pub async fn handle( debug!("Not replying to unproxied PluralKit message"); return Ok(()); } + + regular_role::handle_message(ctx, storage, new_message).await?; } eta::handle(ctx, new_message).await?; diff --git a/src/handlers/event/regular_role.rs b/src/handlers/event/regular_role.rs new file mode 100644 index 00000000..259a57db --- /dev/null +++ b/src/handlers/event/regular_role.rs @@ -0,0 +1,86 @@ +use eyre::Result; +use log::debug; +use poise::serenity_prelude::{Context, EditMember, Member, Message, MessageType, RoleId}; + +use crate::storage::Storage; + +const REQUIRED_CHATTINESS: i64 = 200; +const MAX_PER_DAY: i64 = 25; +const REGULAR_ROLE: RoleId = RoleId::new(1334298718362407063); + +pub async fn handle_member(ctx: &Context, storage: &Storage, member: &Member) -> Result<()> { + if member.roles.contains(®ULAR_ROLE) { + return Ok(()); + } + + let member_id = member.user.id; + + let chattiness = storage.chattiness(member_id.get()).await?; + + if chattiness < REQUIRED_CHATTINESS { + debug!("Not granting regular role for {member_id}: need {REQUIRED_CHATTINESS} but have {chattiness}"); + return Ok(()); + } + + let mut new_roles = member.roles.clone(); + new_roles.push(REGULAR_ROLE); + + debug!("Granting regular role for {member_id}"); + member + .guild_id + .edit_member( + &ctx.http, + member.user.id, + EditMember::new().roles(new_roles), + ) + .await?; + + Ok(()) +} + +pub async fn handle_message(ctx: &Context, storage: &Storage, message: &Message) -> Result<()> { + if message.kind != MessageType::Regular && message.kind != MessageType::InlineReply { + return Ok(()); + } + + let Some(guild_id) = message.guild_id else { + return Ok(()); + }; + let Some(ref member) = message.member else { + return Ok(()); + }; + + if member.roles.contains(®ULAR_ROLE) { + return Ok(()); + } + + let author_id = message.author.id; + + let daily_messages = storage.increase_daily_messages(author_id.get()).await?; + + if daily_messages > MAX_PER_DAY { + debug!("Not increasing chattiness for {author_id}: already have {MAX_PER_DAY} messages"); + return Ok(()); + } + + let chattiness = storage.increase_chattiness(author_id.get()).await?; + + if chattiness < REQUIRED_CHATTINESS { + debug!("Not granting regular role for {author_id}: need {REQUIRED_CHATTINESS} but have {chattiness}"); + return Ok(()); + } + + let mut new_roles = member.roles.clone(); + new_roles.push(REGULAR_ROLE); + + debug!("Granting regular role for {author_id}"); + guild_id + .edit_member( + &ctx.http, + message.author.id, + EditMember::new().roles(new_roles), + ) + .await?; + + Ok(()) +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 1aad0bee..10fe1b3f 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,5 +1,6 @@ use std::fmt::Debug; +use chrono::{Duration, Utc}; use eyre::Result; use log::debug; use poise::serenity_prelude::UserId; @@ -8,6 +9,8 @@ use redis::{AsyncCommands, Client, ConnectionLike}; const PK_KEY: &str = "pluralkit-v1"; const LAUNCHER_VERSION_KEY: &str = "launcher-version-v1"; const LAUNCHER_STARGAZER_KEY: &str = "launcher-stargazer-v1"; +const CHATTINESS_KEY: &str = "chattiness-v1"; +const DAILY_MESSAGES_KEY: &str = "daily-messages-v1"; #[derive(Clone, Debug)] pub struct Storage { @@ -89,4 +92,47 @@ impl Storage { Ok(res) } + + pub async fn increase_daily_messages(&self, id: u64) -> Result { + debug!("Increasing daily message count for {id}"); + let key = format!("{DAILY_MESSAGES_KEY}:{id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + let res: i64 = con.incr(&key, 1).await?; + + let midnight = (Utc::now() + Duration::days(1)) + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("could not determine midnight") + .and_utc(); + + // FIXME(@TheKodeToad): the machine could in theory catch fire in the middle of this and therefore the key would not properly expire + if res == 1 { + () = con + .expire_at(&key, midnight.timestamp()) + .await?; + } + + Ok(res) + } + + pub async fn chattiness(&self, id: u64) -> Result { + debug!("Increasing chattiness for {id}"); + let key = format!("{CHATTINESS_KEY}:{id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + let res: i64 = con.incr(key, 1).await?; + + Ok(res) + } + + pub async fn increase_chattiness(&self, id: u64) -> Result { + debug!("Fetching chattiness for {id}"); + let key = format!("{CHATTINESS_KEY}:{id}"); + + let mut con = self.client.get_multiplexed_async_connection().await?; + let res: i64 = con.get(key).await?; + + Ok(res) + } }