Add leaderboard command and XP winning system
This commit is contained in:
+30
@@ -37,6 +37,11 @@ services:
|
|||||||
command: redis-server --port 7000 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
command: redis-server --port 7000 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||||
ports:
|
ports:
|
||||||
- "7000:7000"
|
- "7000:7000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-p", "7000", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
redis-net:
|
redis-net:
|
||||||
ipv4_address: 172.20.1.2
|
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
|
command: redis-server --port 7001 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||||
ports:
|
ports:
|
||||||
- "7001:7001"
|
- "7001:7001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-p", "7001", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
redis-net:
|
redis-net:
|
||||||
ipv4_address: 172.20.1.3
|
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
|
command: redis-server --port 7002 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||||
ports:
|
ports:
|
||||||
- "7002:7002"
|
- "7002:7002"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-p", "7002", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
redis-net:
|
redis-net:
|
||||||
ipv4_address: 172.20.1.4
|
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:
|
volumes:
|
||||||
scylla-data-1:
|
scylla-data-1:
|
||||||
scylla-data-2:
|
scylla-data-2:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE payphone.users ADD is_banned_from_leaderboard BOOLEAN;
|
||||||
+37
-3
@@ -88,6 +88,7 @@ pub struct User {
|
|||||||
pub last_seen_at: UtcDateTime,
|
pub last_seen_at: UtcDateTime,
|
||||||
pub supporter_since: Option<UtcDateTime>,
|
pub supporter_since: Option<UtcDateTime>,
|
||||||
pub supporter_until: Option<UtcDateTime>,
|
pub supporter_until: Option<UtcDateTime>,
|
||||||
|
pub is_banned_from_leaderboard: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Object<'a> for User {
|
impl<'a> Object<'a> for User {
|
||||||
@@ -101,6 +102,7 @@ impl<'a> Object<'a> for User {
|
|||||||
OffsetDateTime,
|
OffsetDateTime,
|
||||||
Option<OffsetDateTime>,
|
Option<OffsetDateTime>,
|
||||||
Option<OffsetDateTime>,
|
Option<OffsetDateTime>,
|
||||||
|
Option<bool>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fn from_row(row: Self::Row) -> Self {
|
fn from_row(row: Self::Row) -> Self {
|
||||||
@@ -114,6 +116,7 @@ impl<'a> Object<'a> for User {
|
|||||||
last_seen_at: row.6.into(),
|
last_seen_at: row.6.into(),
|
||||||
supporter_since: row.7.map(|i| i.into()),
|
supporter_since: row.7.map(|i| i.into()),
|
||||||
supporter_until: row.8.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.last_seen_at.into(),
|
||||||
value.supporter_since.map(|i| i.into()),
|
value.supporter_since.map(|i| i.into()),
|
||||||
value.supporter_until.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,
|
joined_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
supporter_since,
|
supporter_since,
|
||||||
supporter_until
|
supporter_until,
|
||||||
|
is_banned_from_leaderboard
|
||||||
FROM
|
FROM
|
||||||
payphone.users
|
payphone.users
|
||||||
WHERE
|
WHERE
|
||||||
@@ -191,8 +196,9 @@ impl User {
|
|||||||
joined_at,
|
joined_at,
|
||||||
last_seen_at,
|
last_seen_at,
|
||||||
supporter_since,
|
supporter_since,
|
||||||
supporter_until
|
supporter_until,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
|
is_banned_from_leaderboard
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
"#,
|
"#,
|
||||||
Self::to_row(self.clone()),
|
Self::to_row(self.clone()),
|
||||||
)
|
)
|
||||||
@@ -261,10 +267,38 @@ impl From<TwilightUser> for User {
|
|||||||
last_seen_at: UtcDateTime::now(),
|
last_seen_at: UtcDateTime::now(),
|
||||||
supporter_since: None,
|
supporter_since: None,
|
||||||
supporter_until: 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use deadpool_redis::{cluster::PoolError, redis::RedisError};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use twilight_http::response::DeserializeBodyError;
|
use twilight_http::response::DeserializeBodyError;
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ pub enum CommandError {
|
|||||||
|
|
||||||
#[error("The cache's state was inconsistent.")]
|
#[error("The cache's state was inconsistent.")]
|
||||||
CacheStateInconsistent,
|
CacheStateInconsistent,
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
RedisPoolFailed(#[from] PoolError),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
RedisFailed(#[from] RedisError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
+10
-18
@@ -11,7 +11,9 @@ use twilight_util::builder::embed::{EmbedAuthorBuilder, EmbedBuilder, ImageSourc
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
constants::COLOR_WHITE,
|
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,
|
discord::sanitizing::SanitizeDiscordMarkdown,
|
||||||
framework::{
|
framework::{
|
||||||
events::traits::{
|
events::traits::{
|
||||||
@@ -142,6 +144,8 @@ impl OnMessageHandler<State> for CallMessageHandler {
|
|||||||
return Ok(());
|
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)
|
let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db)
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
@@ -268,7 +272,9 @@ impl OnMessageHandler<State> for CallMessageHandler {
|
|||||||
{
|
{
|
||||||
if error.code == 10015 {
|
if error.code == 10015 {
|
||||||
log::debug!("Webhook not found, updating...");
|
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(());
|
return Ok(());
|
||||||
@@ -297,18 +303,6 @@ impl OnMessageHandler<State> for CallMessageHandler {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule.action() == &Action::BlockAndPenalty)
|
.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?;
|
penalize_user(user, message, rules, &rt).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,11 +368,9 @@ impl OnMessageEditHandler<State> for CallMessageEditHandler {
|
|||||||
.reply(event.id)
|
.reply(event.id)
|
||||||
.await?;
|
.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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
//!
|
//!
|
||||||
//! All of which would be detected.
|
//! All of which would be detected.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, fmt::Debug, sync::Arc};
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use linkify::LinkFinder;
|
use linkify::LinkFinder;
|
||||||
@@ -128,6 +128,7 @@ pub fn escape_message(event: &Message) -> (String, Vec<Arc<dyn Rule>>) {
|
|||||||
let (censored, rules) = moderate_message(&message);
|
let (censored, rules) = moderate_message(&message);
|
||||||
|
|
||||||
log::debug!("Escaped and normalized message: {censored:?}");
|
log::debug!("Escaped and normalized message: {censored:?}");
|
||||||
|
log::debug!("Rules broken: {rules:?}");
|
||||||
|
|
||||||
(censored, rules)
|
(censored, rules)
|
||||||
}
|
}
|
||||||
@@ -377,7 +378,7 @@ pub enum Action {
|
|||||||
|
|
||||||
/// Trait implemented by all rules.
|
/// Trait implemented by all rules.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub trait Rule: Send + Sync {
|
pub trait Rule: Send + Sync + Debug {
|
||||||
/// The rule's human-readable name.
|
/// The rule's human-readable name.
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
@@ -447,6 +448,7 @@ fn find_with_exceptions(message: &str, pattern: &Regex, exceptions: &[String]) -
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct HateSpeechAndSlursRule {
|
struct HateSpeechAndSlursRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -483,6 +485,7 @@ impl Rule for HateSpeechAndSlursRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct ThreatsAndSelfHarmRule {
|
struct ThreatsAndSelfHarmRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -519,6 +522,7 @@ impl Rule for ThreatsAndSelfHarmRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct ExplicitSexualLanguageRule {
|
struct ExplicitSexualLanguageRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -555,6 +559,7 @@ impl Rule for ExplicitSexualLanguageRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct ExplicitSexualLanguageTypoRule {
|
struct ExplicitSexualLanguageTypoRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -591,6 +596,7 @@ impl Rule for ExplicitSexualLanguageTypoRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct SuggestiveLanguageRule {
|
struct SuggestiveLanguageRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -627,6 +633,7 @@ impl Rule for SuggestiveLanguageRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct AdvertisingRule;
|
struct AdvertisingRule;
|
||||||
|
|
||||||
impl Rule for AdvertisingRule {
|
impl Rule for AdvertisingRule {
|
||||||
@@ -656,6 +663,7 @@ impl Rule for AdvertisingRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct SensitiveTopicsAndTriggeringContentRule {
|
struct SensitiveTopicsAndTriggeringContentRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -693,6 +701,7 @@ impl Rule for SensitiveTopicsAndTriggeringContentRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct MildSwearWordsRule {
|
pub struct MildSwearWordsRule {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -728,6 +737,7 @@ impl Rule for MildSwearWordsRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct CasualInsultsAndTeasing {
|
pub struct CasualInsultsAndTeasing {
|
||||||
pattern: Regex,
|
pattern: Regex,
|
||||||
exceptions: Vec<String>,
|
exceptions: Vec<String>,
|
||||||
@@ -763,7 +773,7 @@ impl Rule for CasualInsultsAndTeasing {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
struct LinksRule;
|
struct LinksRule;
|
||||||
|
|
||||||
impl Rule for LinksRule {
|
impl Rule for LinksRule {
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub(super) const KEY_LEADERBOARD_MONTHLY: &str = "lvl:monthly";
|
||||||
@@ -8,16 +8,12 @@ use time::{Date, Month, OffsetDateTime, Time};
|
|||||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::models::Channel,
|
database::models::Channel, framework::{
|
||||||
framework::{
|
|
||||||
events::traits::{EventResult, OnMessageHandler},
|
events::traits::{EventResult, OnMessageHandler},
|
||||||
runtime::Runtime,
|
runtime::Runtime,
|
||||||
},
|
}, groups::leveling::constants::KEY_LEADERBOARD_MONTHLY, state::State
|
||||||
state::State,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const KEY_LEADERBOARD_MONTHLY: &str = "lvl:monthly";
|
|
||||||
|
|
||||||
fn seconds_until_end_of_month() -> i64 {
|
fn seconds_until_end_of_month() -> i64 {
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
let year = now.year();
|
let year = now.year();
|
||||||
@@ -44,6 +40,23 @@ pub struct MessageLevelingHandler;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl OnMessageHandler<State> for MessageLevelingHandler {
|
impl OnMessageHandler<State> for MessageLevelingHandler {
|
||||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> EventResult {
|
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 {
|
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -56,23 +69,30 @@ impl OnMessageHandler<State> for MessageLevelingHandler {
|
|||||||
|
|
||||||
let key = format!("lvl:{}", event.author.id);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let xp = ChaCha20Rng::from_os_rng().random_range(10..=25);
|
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
|
redis
|
||||||
.zincr(KEY_LEADERBOARD_MONTHLY, event.author.id.to_string(), xp)
|
.zincr(KEY_LEADERBOARD_MONTHLY, event.author.id.get(), xp)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let leaderboard_exat = redis.ttl(KEY_LEADERBOARD_MONTHLY).await?;
|
let leaderboard_exat = redis.ttl(KEY_LEADERBOARD_MONTHLY).await?;
|
||||||
|
|
||||||
if leaderboard_exat == IntegerReplyOrNoOp::ExistsButNotRelevant {
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
|
pub mod commands;
|
||||||
|
pub mod constants;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
|||||||
+2
-1
@@ -128,7 +128,7 @@ use crate::{
|
|||||||
about::{commands::HelpCommand, components::HelpGoBackButton}, calls::{
|
about::{commands::HelpCommand, components::HelpGoBackButton}, calls::{
|
||||||
commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand},
|
commands::{CallCommand, FriendRequestCommand, HangupCommand, SkipCommand},
|
||||||
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
|
events::{CallMessageDeleteHandler, CallMessageEditHandler, CallMessageHandler},
|
||||||
}, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::events::MessageLevelingHandler, servers::{
|
}, gateway::events::{ReadyHandler, ReconnectHandler}, leveling::{commands::LeaderboardCommand, events::MessageLevelingHandler}, servers::{
|
||||||
commands::PrefixCommand,
|
commands::PrefixCommand,
|
||||||
events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler},
|
events::{ServerCreateHandler, ServerDeleteHandler, ServerUpdateHandler},
|
||||||
}
|
}
|
||||||
@@ -252,6 +252,7 @@ async fn start(
|
|||||||
.command(HangupCommand)
|
.command(HangupCommand)
|
||||||
.command(FriendRequestCommand)
|
.command(FriendRequestCommand)
|
||||||
.command(PrefixCommand)
|
.command(PrefixCommand)
|
||||||
|
.command(LeaderboardCommand)
|
||||||
.component(HelpGoBackButton)
|
.component(HelpGoBackButton)
|
||||||
.cache_resource_type(ResourceType::GUILD)
|
.cache_resource_type(ResourceType::GUILD)
|
||||||
.cache_resource_type(ResourceType::CHANNEL)
|
.cache_resource_type(ResourceType::CHANNEL)
|
||||||
|
|||||||
Reference in New Issue
Block a user