Add leaderboard command and XP winning system

This commit is contained in:
2025-08-21 02:14:56 -03:00
parent 4563c49245
commit 0318f002fc
11 changed files with 281 additions and 36 deletions
+10 -18
View File
@@ -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<State> 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<State> 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<State> 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<State> 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(());
}
+13 -3
View File
@@ -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<Arc<dyn Rule>>) {
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<String>,
@@ -483,6 +485,7 @@ impl Rule for HateSpeechAndSlursRule {
}
}
#[derive(Debug)]
struct ThreatsAndSelfHarmRule {
pattern: Regex,
exceptions: Vec<String>,
@@ -519,6 +522,7 @@ impl Rule for ThreatsAndSelfHarmRule {
}
}
#[derive(Debug)]
struct ExplicitSexualLanguageRule {
pattern: Regex,
exceptions: Vec<String>,
@@ -555,6 +559,7 @@ impl Rule for ExplicitSexualLanguageRule {
}
}
#[derive(Debug)]
struct ExplicitSexualLanguageTypoRule {
pattern: Regex,
exceptions: Vec<String>,
@@ -591,6 +596,7 @@ impl Rule for ExplicitSexualLanguageTypoRule {
}
}
#[derive(Debug)]
struct SuggestiveLanguageRule {
pattern: Regex,
exceptions: Vec<String>,
@@ -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<String>,
@@ -693,6 +701,7 @@ impl Rule for SensitiveTopicsAndTriggeringContentRule {
}
}
#[derive(Debug)]
pub struct MildSwearWordsRule {
pattern: Regex,
exceptions: Vec<String>,
@@ -728,6 +737,7 @@ impl Rule for MildSwearWordsRule {
}
}
#[derive(Debug)]
pub struct CasualInsultsAndTeasing {
pattern: Regex,
exceptions: Vec<String>,
@@ -763,7 +773,7 @@ impl Rule for CasualInsultsAndTeasing {
)
}
}
#[derive(Debug)]
struct LinksRule;
impl Rule for LinksRule {
+147
View File
@@ -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<State> 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(())
}
}
+1
View File
@@ -0,0 +1 @@
pub(super) const KEY_LEADERBOARD_MONTHLY: &str = "lvl:monthly";
+31 -11
View File
@@ -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<State> for MessageLevelingHandler {
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> 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<State> 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(())
+2
View File
@@ -1 +1,3 @@
pub mod commands;
pub mod constants;
pub mod events;