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
|
||||
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
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
+10
-18
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 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(())
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod constants;
|
||||
pub mod events;
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user