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
+30
View File
@@ -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:
@@ -0,0 +1 @@
ALTER TABLE payphone.users ADD is_banned_from_leaderboard BOOLEAN;
+37 -3
View File
@@ -88,6 +88,7 @@ pub struct User {
pub last_seen_at: UtcDateTime,
pub supporter_since: Option<UtcDateTime>,
pub supporter_until: Option<UtcDateTime>,
pub is_banned_from_leaderboard: Option<bool>,
}
impl<'a> Object<'a> for User {
@@ -101,6 +102,7 @@ impl<'a> Object<'a> for User {
OffsetDateTime,
Option<OffsetDateTime>,
Option<OffsetDateTime>,
Option<bool>,
);
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<TwilightUser> 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<User, DatabaseError>`] - 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<User, DatabaseError> {
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,
+7
View File
@@ -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]
+9 -17
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?;
}
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;
+2 -1
View File
@@ -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)