diff --git a/compose.yml b/compose.yml index d1add0c..33cd164 100644 --- a/compose.yml +++ b/compose.yml @@ -37,6 +37,11 @@ services: command: redis-server --port 7000 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes ports: - "7000:7000" + healthcheck: + test: ["CMD", "redis-cli", "-p", "7000", "ping"] + interval: 5s + timeout: 3s + retries: 5 networks: redis-net: ipv4_address: 172.20.1.2 @@ -47,6 +52,11 @@ services: command: redis-server --port 7001 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes ports: - "7001:7001" + healthcheck: + test: ["CMD", "redis-cli", "-p", "7001", "ping"] + interval: 5s + timeout: 3s + retries: 5 networks: redis-net: ipv4_address: 172.20.1.3 @@ -57,10 +67,30 @@ services: command: redis-server --port 7002 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes ports: - "7002:7002" + healthcheck: + test: ["CMD", "redis-cli", "-p", "7002", "ping"] + interval: 5s + timeout: 3s + retries: 5 networks: redis-net: ipv4_address: 172.20.1.4 + redis-init: + image: redis:latest + container_name: redis-init + command: redis-cli --cluster create 172.20.1.2:7000 172.20.1.3:7001 172.20.1.4:7002 --cluster-replicas 0 --cluster-yes + restart: no + depends_on: + redis-node-1: + condition: service_healthy + redis-node-2: + condition: service_healthy + redis-node-3: + condition: service_healthy + networks: + - redis-net + volumes: scylla-data-1: scylla-data-2: diff --git a/src/database/migrations/0002_leaderboard_bans.cql b/src/database/migrations/0002_leaderboard_bans.cql new file mode 100644 index 0000000..56d4757 --- /dev/null +++ b/src/database/migrations/0002_leaderboard_bans.cql @@ -0,0 +1 @@ +ALTER TABLE payphone.users ADD is_banned_from_leaderboard BOOLEAN; diff --git a/src/database/models.rs b/src/database/models.rs index 226f5d6..f6ab46f 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -88,6 +88,7 @@ pub struct User { pub last_seen_at: UtcDateTime, pub supporter_since: Option, pub supporter_until: Option, + pub is_banned_from_leaderboard: Option, } impl<'a> Object<'a> for User { @@ -101,6 +102,7 @@ impl<'a> Object<'a> for User { OffsetDateTime, Option, Option, + Option, ); fn from_row(row: Self::Row) -> Self { @@ -114,6 +116,7 @@ impl<'a> Object<'a> for User { last_seen_at: row.6.into(), supporter_since: row.7.map(|i| i.into()), supporter_until: row.8.map(|i| i.into()), + is_banned_from_leaderboard: row.9, } } @@ -128,6 +131,7 @@ impl<'a> Object<'a> for User { value.last_seen_at.into(), value.supporter_since.map(|i| i.into()), value.supporter_until.map(|i| i.into()), + Some(value.is_banned_from_leaderboard.unwrap_or(false)), ) } } @@ -153,7 +157,8 @@ impl User { joined_at, last_seen_at, supporter_since, - supporter_until + supporter_until, + is_banned_from_leaderboard FROM payphone.users WHERE @@ -191,8 +196,9 @@ impl User { joined_at, last_seen_at, supporter_since, - supporter_until - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + supporter_until, + is_banned_from_leaderboard + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); "#, Self::to_row(self.clone()), ) @@ -261,10 +267,38 @@ impl From for User { last_seen_at: UtcDateTime::now(), supporter_since: None, supporter_until: None, + is_banned_from_leaderboard: None, } } } +/// Gets a user from the DB and sets their last seen timestamp or inserts a new user from Discord. +/// +/// Arguments: +/// * `user` - The Twilight user to get or create. +/// * `session` - The Scylla session to use for database operations. +/// +/// Returns: +/// [`Result`] - The user from the database or a new user created from +/// Discord. +pub async fn get_or_create_user_and_set_last_seen( + user: TwilightUser, + session: &Session, +) -> Result { + let db_user = User::get_by_id(user.id.get(), session).await?; + + if let Some(mut user) = db_user { + user.last_seen_at = UtcDateTime::now(); + + return Ok(user); + } + + let new_user = User::from(user); + new_user.insert(session).await?; + + Ok(new_user) +} + #[derive(Debug, Clone)] pub struct Server { pub id: u64, diff --git a/src/framework/commands/mod.rs b/src/framework/commands/mod.rs index dcd1cb8..c2bd6f9 100644 --- a/src/framework/commands/mod.rs +++ b/src/framework/commands/mod.rs @@ -1,6 +1,7 @@ use std::fmt::Debug; use async_trait::async_trait; +use deadpool_redis::{cluster::PoolError, redis::RedisError}; use thiserror::Error; use twilight_http::response::DeserializeBodyError; @@ -36,6 +37,12 @@ pub enum CommandError { #[error("The cache's state was inconsistent.")] CacheStateInconsistent, + + #[error(transparent)] + RedisPoolFailed(#[from] PoolError), + + #[error(transparent)] + RedisFailed(#[from] RedisError), } #[async_trait] diff --git a/src/groups/calls/events.rs b/src/groups/calls/events.rs index c721496..ba5b463 100644 --- a/src/groups/calls/events.rs +++ b/src/groups/calls/events.rs @@ -11,7 +11,9 @@ use twilight_util::builder::embed::{EmbedAuthorBuilder, EmbedBuilder, ImageSourc use crate::{ constants::COLOR_WHITE, - database::models::{Call, Channel, Message, User, UserIncident}, + database::models::{ + Call, Channel, Message, User, UserIncident, get_or_create_user_and_set_last_seen, + }, discord::sanitizing::SanitizeDiscordMarkdown, framework::{ events::traits::{ @@ -142,6 +144,8 @@ impl OnMessageHandler for CallMessageHandler { return Ok(()); }; + let user = get_or_create_user_and_set_last_seen(event.author.clone(), &rt.state.db).await?; + let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db) .await? .iter() @@ -268,7 +272,9 @@ impl OnMessageHandler for CallMessageHandler { { if error.code == 10015 { log::debug!("Webhook not found, updating..."); - opposite_channel.update_webhook(&rt.state.db, &rt.http).await?; + opposite_channel + .update_webhook(&rt.state.db, &rt.http) + .await?; } } return Ok(()); @@ -297,18 +303,6 @@ impl OnMessageHandler for CallMessageHandler { .iter() .any(|rule| rule.action() == &Action::BlockAndPenalty) { - let user = User::get_by_id(event.author.id.get(), &rt.state.db).await?; - - let user = if let Some(user) = user { - user - } else { - let user: User = event.author.clone().into(); - - user.insert(&rt.state.db).await?; - - user - }; - penalize_user(user, message, rules, &rt).await?; } @@ -374,11 +368,9 @@ impl OnMessageEditHandler for CallMessageEditHandler { .reply(event.id) .await?; - let user = User::get_by_id(event.author.id.get(), &rt.state.db).await?; + let user = get_or_create_user_and_set_last_seen(event.author.clone(), &rt.state.db).await?; - if let Some(user) = user { - penalize_user(user, message, rules, &rt).await?; - } + penalize_user(user, message, rules, &rt).await?; return Ok(()); } diff --git a/src/groups/calls/moderation.rs b/src/groups/calls/moderation.rs index 0af52b3..1775181 100644 --- a/src/groups/calls/moderation.rs +++ b/src/groups/calls/moderation.rs @@ -83,7 +83,7 @@ //! //! All of which would be detected. -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, fmt::Debug, sync::Arc}; use itertools::Itertools; use linkify::LinkFinder; @@ -128,6 +128,7 @@ pub fn escape_message(event: &Message) -> (String, Vec>) { let (censored, rules) = moderate_message(&message); log::debug!("Escaped and normalized message: {censored:?}"); + log::debug!("Rules broken: {rules:?}"); (censored, rules) } @@ -377,7 +378,7 @@ pub enum Action { /// Trait implemented by all rules. #[allow(dead_code)] -pub trait Rule: Send + Sync { +pub trait Rule: Send + Sync + Debug { /// The rule's human-readable name. fn name(&self) -> &'static str; @@ -447,6 +448,7 @@ fn find_with_exceptions(message: &str, pattern: &Regex, exceptions: &[String]) - .collect() } +#[derive(Debug)] struct HateSpeechAndSlursRule { pattern: Regex, exceptions: Vec, @@ -483,6 +485,7 @@ impl Rule for HateSpeechAndSlursRule { } } +#[derive(Debug)] struct ThreatsAndSelfHarmRule { pattern: Regex, exceptions: Vec, @@ -519,6 +522,7 @@ impl Rule for ThreatsAndSelfHarmRule { } } +#[derive(Debug)] struct ExplicitSexualLanguageRule { pattern: Regex, exceptions: Vec, @@ -555,6 +559,7 @@ impl Rule for ExplicitSexualLanguageRule { } } +#[derive(Debug)] struct ExplicitSexualLanguageTypoRule { pattern: Regex, exceptions: Vec, @@ -591,6 +596,7 @@ impl Rule for ExplicitSexualLanguageTypoRule { } } +#[derive(Debug)] struct SuggestiveLanguageRule { pattern: Regex, exceptions: Vec, @@ -627,6 +633,7 @@ impl Rule for SuggestiveLanguageRule { } } +#[derive(Debug)] struct AdvertisingRule; impl Rule for AdvertisingRule { @@ -656,6 +663,7 @@ impl Rule for AdvertisingRule { } } +#[derive(Debug)] struct SensitiveTopicsAndTriggeringContentRule { pattern: Regex, exceptions: Vec, @@ -693,6 +701,7 @@ impl Rule for SensitiveTopicsAndTriggeringContentRule { } } +#[derive(Debug)] pub struct MildSwearWordsRule { pattern: Regex, exceptions: Vec, @@ -728,6 +737,7 @@ impl Rule for MildSwearWordsRule { } } +#[derive(Debug)] pub struct CasualInsultsAndTeasing { pattern: Regex, exceptions: Vec, @@ -763,7 +773,7 @@ impl Rule for CasualInsultsAndTeasing { ) } } - +#[derive(Debug)] struct LinksRule; impl Rule for LinksRule { diff --git a/src/groups/leveling/commands.rs b/src/groups/leveling/commands.rs new file mode 100644 index 0000000..2a3f85f --- /dev/null +++ b/src/groups/leveling/commands.rs @@ -0,0 +1,147 @@ +use async_trait::async_trait; +use deadpool_redis::redis::AsyncTypedCommands; +use itertools::Itertools; +use twilight_util::builder::embed::{ + EmbedAuthorBuilder, EmbedBuilder, EmbedFooterBuilder, ImageSource, +}; + +use crate::{ + constants::COLOR_WHITE, + database::models::User, + discord::{images, sanitizing::SanitizeDiscordMarkdown}, + framework::commands::{Command, CommandError, context::CommandContext}, + groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, + state::State, +}; + +#[derive(Debug)] +pub struct LeaderboardCommand; + +#[async_trait] +impl Command for LeaderboardCommand { + fn name(&self) -> &'static str { + "leaderboard" + } + + fn aliases(&self) -> &'static [&'static str] { + &["lb"] + } + + fn description(&self) -> &'static str { + "Lists the top users in the leaderboard and your position in it." + } + + async fn execute( + &self, + ctx: CommandContext<'_, State>, + _args: &str, + ) -> Result<(), CommandError> { + let mut redis = ctx + .rt + .state + .redis + .get() + .await + .map_err(CommandError::RedisPoolFailed)?; + + let top = redis + .zrange_withscores(KEY_LEADERBOARD_MONTHLY, 0, 9) + .await + .map_err(CommandError::RedisFailed)?; + + log::trace!("Top users for leaderboard: {top:?}"); + + let current_user_rank = redis + .zrank(KEY_LEADERBOARD_MONTHLY, ctx.event.author.id.get()) + .await + .map_err(CommandError::RedisFailed)?; + + let mut users = Vec::new(); + + for (user_id, score) in top { + let id: u64 = user_id + .parse() + .expect("User ID could not be parsed for leaderboard"); + + let Some(user) = User::get_by_id(id, &ctx.rt.state.db) + .await + .map_err(|_| CommandError::DatabaseQueryFailed)? + else { + log::trace!("User ID {id} not found for leaderboard"); + continue; + }; + + if let Some(is_banned_from_leaderboard) = user.is_banned_from_leaderboard { + if is_banned_from_leaderboard { + log::trace!( + "Skipping user {} (ID: {}) because they are banned from the leaderboard.", + user.name, + user.id + ); + continue; + } + } + + users.push((user, score as u32)); + } + + let description = if !users.is_empty() { + users + .iter() + .enumerate() + .map(|(i, (user, xp))| { + format!("#{}: **{}** - {} XP", i + 1, user.name.sanitize(true), xp) + }) + .join("\n") + } else { + "*Nobody earned XP this month yet.*".to_string() + }; + + let footer = if let Some(rank) = current_user_rank { + format!("Your current rank: #{}", rank + 1) + } else { + "You haven't earned XP this month yet.".to_string() + }; + + let embed = EmbedBuilder::new() + .color(COLOR_WHITE) + .title("Monthly Leaderboard") + .description(description) + .author( + EmbedAuthorBuilder::new(ctx.rt.app.name.clone()).icon_url( + ImageSource::url(images::get_avatar_url( + ctx.rt.app.bot.as_ref().unwrap().avatar, + ctx.rt.app.bot.as_ref().unwrap().id.get(), + ctx.rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap(), + ), + ) + .thumbnail( + ImageSource::url(images::get_avatar_url( + ctx.rt.app.bot.as_ref().unwrap().avatar, + ctx.rt.app.bot.as_ref().unwrap().id.get(), + ctx.rt.app.bot.as_ref().unwrap().discriminator as u64, + )) + .unwrap(), + ) + .footer( + EmbedFooterBuilder::new(footer).icon_url( + ImageSource::url(images::get_avatar_url( + ctx.event.author.avatar, + ctx.event.author.id.get(), + ctx.event.author.discriminator as u64, + )) + .unwrap(), + ), + ); + + ctx.send() + .embed(embed.build()) + .await + .inspect_err(|e| log::error!("Failed to send leaderboard embed: {e}")) + .map_err(|_| CommandError::MessageSendingFailed)?; + + Ok(()) + } +} diff --git a/src/groups/leveling/constants.rs b/src/groups/leveling/constants.rs new file mode 100644 index 0000000..b157899 --- /dev/null +++ b/src/groups/leveling/constants.rs @@ -0,0 +1 @@ +pub(super) const KEY_LEADERBOARD_MONTHLY: &str = "lvl:monthly"; diff --git a/src/groups/leveling/events.rs b/src/groups/leveling/events.rs index f0b9e3b..8e15ab8 100644 --- a/src/groups/leveling/events.rs +++ b/src/groups/leveling/events.rs @@ -8,16 +8,12 @@ use time::{Date, Month, OffsetDateTime, Time}; use twilight_model::gateway::payload::incoming::MessageCreate; use crate::{ - database::models::Channel, - framework::{ + database::models::Channel, framework::{ events::traits::{EventResult, OnMessageHandler}, runtime::Runtime, - }, - state::State, + }, groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, state::State }; -const KEY_LEADERBOARD_MONTHLY: &str = "lvl:monthly"; - fn seconds_until_end_of_month() -> i64 { let now = OffsetDateTime::now_utc(); let year = now.year(); @@ -44,6 +40,23 @@ pub struct MessageLevelingHandler; #[async_trait] impl OnMessageHandler for MessageLevelingHandler { async fn on_message(&self, event: &MessageCreate, rt: Arc>) -> EventResult { + if event.author.bot || event.author.system.unwrap_or(false) { + return Ok(()); + } + + let prefixes = rt + .get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get())) + .await; + + if prefixes.iter().any(|prefix| { + event + .content + .to_ascii_lowercase() + .starts_with(&prefix.to_ascii_lowercase()) + }) { + return Ok(()); + } + let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? else { return Ok(()); }; @@ -56,23 +69,30 @@ impl OnMessageHandler for MessageLevelingHandler { let key = format!("lvl:{}", event.author.id); - let lock = redis.exists(&key).await?; + let lock = redis.ttl(&key).await?; - if lock { + if let IntegerReplyOrNoOp::IntegerReply(seconds) = lock { + log::trace!("No XP for the message because the user is already in the cooldown period. Remaining seconds: {seconds}"); return Ok(()); } let xp = ChaCha20Rng::from_os_rng().random_range(10..=25); - redis.set_ex(&key, true, 60).await?; + log::trace!( + "Adding {xp} XP to user {} in channel {}", + event.author.name, + event.channel_id + ); + + redis.set_ex(&key, "", 60).await?; redis - .zincr(KEY_LEADERBOARD_MONTHLY, event.author.id.to_string(), xp) + .zincr(KEY_LEADERBOARD_MONTHLY, event.author.id.get(), xp) .await?; let leaderboard_exat = redis.ttl(KEY_LEADERBOARD_MONTHLY).await?; if leaderboard_exat == IntegerReplyOrNoOp::ExistsButNotRelevant { - redis.expire(key, seconds_until_end_of_month()).await?; + redis.expire(KEY_LEADERBOARD_MONTHLY, seconds_until_end_of_month()).await?; } Ok(()) diff --git a/src/groups/leveling/mod.rs b/src/groups/leveling/mod.rs index a9970c2..ba4fe43 100644 --- a/src/groups/leveling/mod.rs +++ b/src/groups/leveling/mod.rs @@ -1 +1,3 @@ +pub mod commands; +pub mod constants; pub mod events; diff --git a/src/main.rs b/src/main.rs index 7512022..6c62343 100644 --- a/src/main.rs +++ b/src/main.rs @@ -128,7 +128,7 @@ use crate::{ about::{commands::HelpCommand, components::HelpGoBackButton}, calls::{ commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand}, events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler}, - }, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::events::MessageLevelingHandler, servers::{ + }, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler}, servers::{ commands::PrefixCommand, events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler}, } @@ -252,6 +252,7 @@ async fn start( .command(HangupCommand) .command(FriendRequestCommand) .command(PrefixCommand) + .command(LeaderboardCommand) .component(HelpGoBackButton) .cache_resource_type(ResourceType::GUILD) .cache_resource_type(ResourceType::CHANNEL)