From f758b24f07d6e01cd234fba99afc3e373c914b85 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Fri, 11 Jul 2025 21:09:20 +0200 Subject: [PATCH 1/8] add enabled_commands to config --- src/models/config/commands.rs | 23 +++++++++++++++++++++++ src/models/config/mod.rs | 6 ++++++ src/models/config/moderation.rs | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/models/config/commands.rs diff --git a/src/models/config/commands.rs b/src/models/config/commands.rs new file mode 100644 index 0000000..90efaba --- /dev/null +++ b/src/models/config/commands.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; + +/// Disabled commands indicates what commands should not be registered as guild commands +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct Commands { + pub case_details: bool, + pub case_edit: bool, + pub case_last: bool, + pub case_list: bool, + pub case_remove: bool, + + pub clear: bool, + pub ban: bool, + pub kick: bool, + pub mute: bool, + pub timeout: bool, + pub warn: bool, + + pub top_day_all: bool, + pub top_day_me: bool, + pub top_week_all: bool, + pub top_week_me: bool +} \ No newline at end of file diff --git a/src/models/config/mod.rs b/src/models/config/mod.rs index 745a76d..f716ee3 100644 --- a/src/models/config/mod.rs +++ b/src/models/config/mod.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::sync::Arc; use serde::{Serialize, Deserialize}; use twilight_model::id::Id; use twilight_model::id::marker::{ApplicationMarker, GuildMarker}; use crate::models::config::activity::{Levels, Top}; +use crate::models::config::commands::Commands; use crate::models::config::moderation::Moderation; use self::automod::actions::BucketAction; @@ -10,11 +12,14 @@ use self::automod::actions::BucketAction; pub mod moderation; pub mod activity; pub mod automod; +pub mod commands; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GuildConfig { pub guild_id: Id, pub application_id: Option>, + pub enabled_commands: Commands, + pub moderation: Option, pub premium: bool, pub levels: Option, @@ -26,6 +31,7 @@ impl GuildConfig { Self { guild_id, application_id: None, + enabled_commands: Default::default(), moderation: None, premium: false, levels: None, diff --git a/src/models/config/moderation.rs b/src/models/config/moderation.rs index 2983499..55ffa2e 100644 --- a/src/models/config/moderation.rs +++ b/src/models/config/moderation.rs @@ -20,5 +20,6 @@ pub struct Moderation { pub mute_role: Option>, pub native_support: bool, pub logs_channel: Option>, - pub dm_case: bool + pub dm_case: bool, + pub context_menu: bool } \ No newline at end of file From a411c1e1890a1703721a7940bf67d7f0053c6aa4 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:19:38 +0200 Subject: [PATCH 2/8] generate commands list to change in guild --- src/server/guild/commands/activity.rs | 85 +++++++++++++ src/server/guild/commands/cases.rs | 134 ++++++++++++++++++++ src/server/guild/commands/mod.rs | 144 +++++++++++++++++++++ src/server/guild/commands/moderation.rs | 162 ++++++++++++++++++++++++ src/server/mod.rs | 1 + 5 files changed, 526 insertions(+) create mode 100644 src/server/guild/commands/activity.rs create mode 100644 src/server/guild/commands/cases.rs create mode 100644 src/server/guild/commands/mod.rs create mode 100644 src/server/guild/commands/moderation.rs diff --git a/src/server/guild/commands/activity.rs b/src/server/guild/commands/activity.rs new file mode 100644 index 0000000..6a6306d --- /dev/null +++ b/src/server/guild/commands/activity.rs @@ -0,0 +1,85 @@ +use crate::models::config::GuildConfig; +use crate::server::guild::commands::{default_option, defaults_command, if_enabled, options}; +use twilight_model::application::command::{Command, CommandOption, CommandOptionType}; + +macro_rules! subcommand { + ($name: expr, $description: expr) => { + CommandOption { + name: $name.to_string(), + description: $description.to_string(), + kind: CommandOptionType::SubCommand, + ..default_option() + } + }; +} + +macro_rules! group { + ($name: expr, $subcommands: expr) => { + CommandOption { + name: $name.to_string(), + description: "-".to_string(), + kind: CommandOptionType::SubCommandGroup, + options: Some($subcommands), + ..default_option() + } + }; +} + +pub(super) fn add_activity_commands(config: &GuildConfig, commands: &mut Vec) { + if let Some(top) = &config.top { + let mut subcommand_groups = vec![]; + + if top.day { + let mut subcommands = vec![]; + + if_enabled!( + config, + top_day_all, + subcommands, + subcommand!("all", "Shows top 3 active users") + ); + + if_enabled!( + config, + top_day_me, + subcommands, + subcommand!("me", "Shows your position on the leaderboard") + ); + + if !subcommands.is_empty() { + subcommand_groups.push(group!("day", subcommands)); + } + } + + if top.week { + let mut subcommands = vec![]; + + if_enabled!( + config, + top_week_all, + subcommands, + subcommand!("all", "Shows top 3 active users") + ); + + if_enabled!( + config, + top_week_me, + subcommands, + subcommand!("me", "Shows your position on the leaderboard") + ); + + if !subcommands.is_empty() { + subcommand_groups.push(group!("week", subcommands)); + } + } + + if !subcommand_groups.is_empty() { + commands.push(Command { + name: "top".to_string(), + description: "-".to_string(), + options: subcommand_groups, + ..defaults_command() + }) + } + } +} diff --git a/src/server/guild/commands/cases.rs b/src/server/guild/commands/cases.rs new file mode 100644 index 0000000..7b21400 --- /dev/null +++ b/src/server/guild/commands/cases.rs @@ -0,0 +1,134 @@ +use crate::models::config::GuildConfig; +use crate::server::guild::commands::{ + choice, default_option, defaults_command, if_enabled, options, subcommand, +}; +use twilight_model::application::command::{ + Command, CommandOption, CommandOptionType, CommandOptionValue, CommandType, +}; +use twilight_model::guild::Permissions; + +pub(super) fn add_case_command(config: &GuildConfig, commands: &mut Vec) { + let mut case_options = vec![]; + + if_enabled!( + config, + case_last, + case_options, + subcommand!( + "last", + "Last punishment this user has received", + vec![options!( + "member", + "The user that is subject to punishment that will be displayed", + CommandOptionType::User, + true + )] + ) + ); + + if_enabled!( + config, + case_list, + case_options, + subcommand!( + "list", + "Shows history of user's punishment", + vec![ + options!( + "member", + "The user that is subject to punishment that will be displayed", + CommandOptionType::User, + true + ), + CommandOption { + name: "page".to_string(), + description: "The page you want to retrieve".to_string(), + min_value: Some(CommandOptionValue::Integer(1)), + kind: CommandOptionType::Integer, + ..default_option() + }, + CommandOption { + name: "type".to_string(), + description: "Moderation action you want to see".to_string(), + kind: CommandOptionType::String, + choices: Some(vec![ + choice!("mutes"), + choice!("warns"), + choice!("kicks"), + choice!("bans"), + ]), + ..default_option() + } + ] + ) + ); + + if_enabled!( + config, + case_details, + case_options, + subcommand!( + "details", + "Shows detailed information about a case", + vec![options!( + "number", + "The case number you want to see", + CommandOptionType::Integer, + true + ),] + ) + ); + + if_enabled!( + config, + case_remove, + case_options, + subcommand!( + "remove", + "Removes a case from user's history", + vec![options!( + "number", + "Number of a case you want to delete", + CommandOptionType::Integer, + true + ),] + ) + ); + + if_enabled!( + config, + case_edit, + case_options, + subcommand!( + "edit", + "Edit case reason", + vec![ + options!( + "number", + "Number of a case you want to edit", + CommandOptionType::Integer, + true + ), + CommandOption { + name: "reason".to_string(), + description: "New reason".to_string(), + kind: CommandOptionType::String, + required: Some(true), + max_length: Some(512), + ..default_option() + } + ] + ) + ); + + if !case_options.is_empty() { + commands.push(Command { + name: "case".to_string(), + description: "-".to_string(), + options: case_options, + kind: CommandType::ChatInput, + default_member_permissions: Some(Permissions::MODERATE_MEMBERS), + ..defaults_command() + }) + } +} diff --git a/src/server/guild/commands/mod.rs b/src/server/guild/commands/mod.rs new file mode 100644 index 0000000..2f9d8e3 --- /dev/null +++ b/src/server/guild/commands/mod.rs @@ -0,0 +1,144 @@ +mod activity; +mod cases; +mod moderation; + +use crate::models::config::GuildConfig; +use twilight_model::application::command::{ + Command, CommandOption, CommandOptionType, CommandType, +}; +use twilight_model::application::interaction::InteractionContextType; +use twilight_model::guild::Permissions; +use twilight_model::id::Id; +use twilight_model::oauth::ApplicationIntegrationType; + +pub(self) fn defaults_command() -> Command { + Command { + application_id: None, + contexts: Some(vec![InteractionContextType::Guild]), + default_member_permissions: Some(Permissions::empty()), + #[allow(deprecated)] + dm_permission: None, + description: "".to_string(), + description_localizations: None, + guild_id: None, + id: None, + integration_types: Some(vec![ApplicationIntegrationType::GuildInstall]), + kind: CommandType::ChatInput, + name: "".to_string(), + name_localizations: None, + nsfw: None, + options: vec![], + version: Id::new(1), + } +} + +pub(self) fn default_option() -> CommandOption { + CommandOption { + autocomplete: None, + channel_types: None, + choices: None, + description: "".to_string(), + description_localizations: None, + kind: CommandOptionType::SubCommand, + max_length: None, + max_value: None, + min_length: None, + min_value: None, + name: "".to_string(), + name_localizations: None, + options: None, + required: None, + } +} + +macro_rules! options { + ($name: expr, $description: expr, $kind: expr, $required: expr) => { + CommandOption { + name: $name.to_string(), + kind: $kind, + required: Some($required), + description: $description.to_string(), + ..default_option() + } + }; +} +pub(self) use options; + +macro_rules! subcommand { + ($name: expr, $description: expr, $options: expr) => { + CommandOption { + name: $name.to_string(), + description: $description.to_string(), + kind: CommandOptionType::SubCommand, + options: Some($options), + ..default_option() + } + }; +} +pub(self) use subcommand; + +macro_rules! define_context_menu { + ($name: tt, $command_name: expr, $permissions: expr) => { + fn $name() -> Command { + twilight_model::application::command::Command { + name: $command_name.to_string(), + kind: twilight_model::application::command::CommandType::User, + default_member_permissions: Some($permissions), + ..defaults_command() + } + } + }; +} + +pub(self) use define_context_menu; + +macro_rules! define_command { + ($name: tt, $command_name: expr, $description: expr, $options: expr, $permissions: expr) => { + fn $name() -> Command { + twilight_model::application::command::Command { + name: $command_name.to_string(), + description: $description.to_string(), + options: $options, + default_member_permissions: Some($permissions), + ..defaults_command() + } + } + }; +} +pub(self) use define_command; + +macro_rules! choice { + ($name: expr) => { + twilight_model::application::command::CommandOptionChoice { + name: $name.to_string(), + name_localizations: None, + value: twilight_model::application::command::CommandOptionChoiceValue::String( + $name.to_string(), + ), + } + }; +} +pub(self) use choice; + +macro_rules! if_enabled { + ($config: expr, $field: tt, $commands: expr, $value: expr) => { + if $config.enabled_commands.$field { + $commands.push($value); + } + }; +} +pub(self) use if_enabled; + +use crate::server::guild::commands::activity::add_activity_commands; +use crate::server::guild::commands::cases::add_case_command; +use crate::server::guild::commands::moderation::add_moderation_commands; + +pub fn get_guild_commands_list(config: &GuildConfig) -> Vec { + let mut commands = vec![]; + + add_moderation_commands(config, &mut commands); + add_activity_commands(config, &mut commands); + add_case_command(config, &mut commands); + + commands +} diff --git a/src/server/guild/commands/moderation.rs b/src/server/guild/commands/moderation.rs new file mode 100644 index 0000000..21ebab9 --- /dev/null +++ b/src/server/guild/commands/moderation.rs @@ -0,0 +1,162 @@ +use crate::models::config::GuildConfig; +use crate::models::config::moderation::MuteMode; +use crate::server::guild::commands::{ + choice, default_option, defaults_command, define_command, define_context_menu, if_enabled, + options, +}; +use twilight_model::application::command::{ + Command, CommandOption, CommandOptionType, CommandOptionValue, +}; +use twilight_model::guild::Permissions; + +fn moderation_action() -> Vec { + vec![ + options!("member", "Punished user", CommandOptionType::User, true), + options!( + "reason", + "Reason for punishment", + CommandOptionType::String, + false + ), + ] +} + +fn moderation_action_with_duration(required: bool) -> Vec { + vec![ + options!("member", "Punished user", CommandOptionType::User, true), + options!( + "duration", + "Duration of punishment (eg. 1 minute, 1 week, 10m, 30s)", + CommandOptionType::String, + required + ), + options!( + "reason", + "Reason for punishment", + CommandOptionType::String, + false + ), + ] +} + +define_command!( + timeout_command, + "timeout", + "Timeouts users", + moderation_action_with_duration(true), + Permissions::MODERATE_MEMBERS +); +define_command!( + mute_command, + "mute", + "Mutes users", + moderation_action_with_duration(false), + Permissions::MODERATE_MEMBERS +); +define_command!( + kick_command, + "kick", + "Kicks users", + moderation_action(), + Permissions::KICK_MEMBERS +); +define_command!( + warn_command, + "warn", + "Warns users", + moderation_action(), + Permissions::MODERATE_MEMBERS +); +define_command!( + ban_command, + "ban", + "Bans users", + moderation_action_with_duration(false), + Permissions::BAN_MEMBERS +); + +define_context_menu!( + timeout_context_menu, + "Timeout", + Permissions::MODERATE_MEMBERS +); +define_context_menu!(mute_context_menu, "Mute", Permissions::MODERATE_MEMBERS); +define_context_menu!(kick_context_menu, "Kick", Permissions::KICK_MEMBERS); +define_context_menu!(warn_context_menu, "Warn", Permissions::MODERATE_MEMBERS); +define_context_menu!(ban_context_menu, "Ban", Permissions::BAN_MEMBERS); + +fn clear_options() -> Vec { + vec![ + CommandOption { + name: "amount".to_string(), + description: "Amount of messages you want to consider for deletion".to_string(), + min_value: Some(CommandOptionValue::Integer(1)), + max_value: Some(CommandOptionValue::Integer(600)), + kind: CommandOptionType::Integer, + required: Some(true), + ..default_option() + }, + options!("author", "Message author", CommandOptionType::User, false), + CommandOption { + name: "filter".to_string(), + kind: CommandOptionType::String, + description: "Message characteristic you want to filter by".to_string(), + choices: Some(vec![ + choice!("system"), + choice!("attachments"), + choice!("stickers"), + choice!("embeds"), + choice!("bots"), + ]), + ..default_option() + }, + ] +} + +define_command!( + clear_command, + "clear", + "Deletes given amount of messages", + clear_options(), + Permissions::MANAGE_MESSAGES +); + +pub(super) fn add_moderation_commands(config: &GuildConfig, commands: &mut Vec) { + if let Some(moderation) = &config.moderation { + match moderation.mute_mode { + MuteMode::DependOnCommand => { + if_enabled!(config, timeout, commands, timeout_command()); + if_enabled!(config, mute, commands, mute_command()); + + if moderation.context_menu { + if_enabled!(config, timeout, commands, timeout_context_menu()); + if_enabled!(config, mute, commands, mute_context_menu()); + } + } + MuteMode::Timeout => { + if_enabled!(config, timeout, commands, timeout_command()); + if moderation.context_menu { + if_enabled!(config, timeout, commands, timeout_context_menu()); + } + } + MuteMode::Role => { + if_enabled!(config, mute, commands, mute_command()); + if moderation.context_menu { + if_enabled!(config, mute, commands, mute_context_menu()); + } + } + }; + + if_enabled!(config, warn, commands, warn_command()); + if_enabled!(config, kick, commands, kick_command()); + if_enabled!(config, ban, commands, ban_command()); + + if moderation.context_menu { + if_enabled!(config, warn, commands, warn_context_menu()); + if_enabled!(config, kick, commands, kick_context_menu()); + if_enabled!(config, ban, commands, ban_context_menu()); + } + + if_enabled!(config, clear, commands, clear_command()); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index 0aff6a0..c559be3 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -25,6 +25,7 @@ pub mod authorize; #[cfg(feature = "api")] pub mod guild { + pub mod commands; pub mod editing; pub mod ws; } From a3a061b0e7f46d94a8eb4beec52d5074c97c1578 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:19:51 +0200 Subject: [PATCH 3/8] update debug config --- src/utils/config.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/utils/config.rs b/src/utils/config.rs index 3815d97..9471bb2 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -3,14 +3,32 @@ use std::collections::HashMap; use twilight_model::id::Id; use crate::models::config::{GuildConfig, moderation::{Moderation, MuteMode}, automod::{AutoModeration, AutoModerationRule, ignore::{Ignore, IgnoreMode}, actions::{ActionMetadata, Action, IncreaseBucket, IncreaseBucketAmount, BucketAction, Timeout}}, activity::{Levels, Top}}; +use crate::models::config::commands::Commands; #[allow(dead_code)] /// Creates config for warns pub fn create_debug_config() -> GuildConfig { GuildConfig { - guild_id: Id::new(981950094804930581), + guild_id: Id::new(759848600833490945), application_id: None, + enabled_commands: Commands { + case_details: true, + case_edit: true, + case_last: true, + case_list: true, + case_remove: true, + clear: true, + ban: true, + kick: true, + mute: true, + timeout: true, + warn: true, + top_day_all: true, + top_day_me: true, + top_week_all: true, + top_week_me: true, + }, moderation: Some(Moderation { automod: Some(AutoModeration { rules: vec![AutoModerationRule { @@ -61,6 +79,7 @@ pub fn create_debug_config() -> GuildConfig { native_support: true, logs_channel: Some(Id::new(981950096801406979)), dm_case: true, + context_menu: true, }), premium: true, levels: Some(Levels { From bc8dcf320553b834f0f02db8f6bbd0ef564e65d4 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:32:55 +0200 Subject: [PATCH 4/8] fix clear command option names --- src/commands/moderation/clear.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/moderation/clear.rs b/src/commands/moderation/clear.rs index 8f28686..9ad8858 100644 --- a/src/commands/moderation/clear.rs +++ b/src/commands/moderation/clear.rs @@ -27,12 +27,12 @@ pub async fn run( interaction.options.get("amount"), CommandOptionValue::Integer ); - let member = get_option!( - interaction.options.get("member"), CommandOptionValue::User + let author = get_option!( + interaction.options.get("author"), CommandOptionValue::User ).copied(); let filter = get_option!( - interaction.options.get("member"), CommandOptionValue::String + interaction.options.get("filter"), CommandOptionValue::String ); if !(&2..=&600).contains(&amount) { @@ -61,7 +61,7 @@ pub async fn run( last = messages.last().map(|msg| msg.id); - if let Some(member) = member { + if let Some(member) = author { messages = messages.iter() .filter(|msg| msg.author.id == member) .cloned().collect::>(); From 3224641c896192d8f270e812e71f9d92393cfd9a Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:11:03 +0200 Subject: [PATCH 5/8] query instead of headers auth --- src/server/routes/guilds/_id.rs | 4 ++-- src/server/session.rs | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/server/routes/guilds/_id.rs b/src/server/routes/guilds/_id.rs index 2b98884..61e669b 100644 --- a/src/server/routes/guilds/_id.rs +++ b/src/server/routes/guilds/_id.rs @@ -12,7 +12,7 @@ use crate::context::Context; use crate::server::error::{MapErrorIntoInternalRejection, Rejection}; use crate::server::guild::editing::GuildsEditing; use crate::server::guild::ws::handle_connection; -use crate::server::session::{Authenticator, AuthorizationInformation, authorize_user, Sessions}; +use crate::server::session::{Authenticator, AuthorizationInformation, Sessions, query_authorize_user}; type GuildId = Id; @@ -27,7 +27,7 @@ pub fn run( let with_guilds_editing = with_value!(guilds_editing); warp::path!("guilds" / GuildId) - .and(authorize_user(authenticator, sessions)) + .and(query_authorize_user(authenticator, sessions)) .and(with_context.clone()) .and_then(check_guild) .and(warp::ws()) diff --git a/src/server/session.rs b/src/server/session.rs index 2e75013..0f3b3aa 100644 --- a/src/server/session.rs +++ b/src/server/session.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use rusty_paseto::core::{ImplicitAssertion, Key, Local, PasetoSymmetricKey, V4}; use rusty_paseto::generic::GenericBuilderError; use rusty_paseto::prelude::{PasetoBuilder, PasetoParser}; +use serde::Deserialize; use tokio::sync::RwLock; use twilight_http::Client; use twilight_model::id::Id; @@ -109,4 +110,40 @@ async fn filter( sessions.user(&user_id) .await .ok_or_else(|| reject!(Rejection::Unauthorized)) +} + +#[derive(Deserialize)] +struct Query { + #[serde(rename = "Authorization")] + pub token: String, + #[serde(rename = "User-Id")] + pub user_id: Id +} + +pub fn query_authorize_user( + authenticator: Arc, + sessions: Arc +) -> impl Filter,), Error = warp::Rejection> + Clone { + let with_authenticator = with_value!(authenticator); + let with_sessions = with_value!(sessions); + + warp::any() + .and(warp::query::()) + .and(with_authenticator) + .and(with_sessions) + .and_then(query_filter) +} + +async fn query_filter( + query: Query, + authenticator: Arc, + sessions: Arc +) -> Result, warp::Rejection> { + if authenticator.verify_token(query.token.as_str(), query.user_id) { + return err!(Rejection::Unauthorized) + } + + sessions.user(&query.user_id) + .await + .ok_or_else(|| reject!(Rejection::Unauthorized)) } \ No newline at end of file From 3cee63aff739f175d36bc5537a7fd97fe6241893 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:42:45 +0200 Subject: [PATCH 6/8] generate bitfields based on what commands should be registered --- src/server/guild/commands/bitfield.rs | 109 ++++++++++++++++++++++++++ src/server/guild/commands/mod.rs | 1 + 2 files changed, 110 insertions(+) create mode 100644 src/server/guild/commands/bitfield.rs diff --git a/src/server/guild/commands/bitfield.rs b/src/server/guild/commands/bitfield.rs new file mode 100644 index 0000000..6a38464 --- /dev/null +++ b/src/server/guild/commands/bitfield.rs @@ -0,0 +1,109 @@ +use crate::models::config::GuildConfig; +use crate::models::config::moderation::MuteMode; + +macro_rules! if_enabled { + ($config: expr, $field: tt, $value: expr, $n: expr) => { + if $config.enabled_commands.$field { + $value |= (1 << $n); + } + }; +} + +const TIMEOUT_COMMAND: u32 = 1; +const MUTE_COMMAND: u32 = 2; +const WARN_COMMAND: u32 = 3; +const KICK_COMMAND: u32 = 4; +const BAN_COMMAND: u32 = 5; + +const CONTEXT_MENUS: u32 = 6; + +const CLEAR_COMMAND: u32 = 7; + +const CASE_LAST_COMMAND: u32 = 8; +const CASE_LIST_COMMAND: u32 = 9; +const CASE_DETAILS_COMMAND: u32 = 10; +const CASE_REMOVE_COMMAND: u32 = 11; +const CASE_EDIT_COMMAND: u32 = 12; + +const TOP_DAY_ME_COMMAND: u32 = 13; +const TOP_DAY_ALL_COMMAND: u32 = 14; +const TOP_WEEK_ME_COMMAND: u32 = 15; +const TOP_WEEK_ALL_COMMAND: u32 = 16; + +pub fn get_enabled_bitfield(config: &GuildConfig) -> u32 { + let mut value = 0; + + if let Some(moderation) = &config.moderation { + match moderation.mute_mode { + MuteMode::DependOnCommand => { + if_enabled!(config, timeout, value, TIMEOUT_COMMAND); + if_enabled!(config, mute, value, MUTE_COMMAND); + + if moderation.context_menu { + if_enabled!(config, timeout, value, CONTEXT_MENUS); + if_enabled!(config, mute, value, CONTEXT_MENUS); + } + } + MuteMode::Timeout => { + if_enabled!(config, timeout, value, TIMEOUT_COMMAND); + if moderation.context_menu { + if_enabled!(config, mute, value, CONTEXT_MENUS); + } + } + MuteMode::Role => { + if_enabled!(config, mute, value, MUTE_COMMAND); + if moderation.context_menu { + if_enabled!(config, mute, value, CONTEXT_MENUS); + } + } + }; + + if_enabled!(config, warn, value, WARN_COMMAND); + if_enabled!(config, kick, value, KICK_COMMAND); + if_enabled!(config, ban, value, BAN_COMMAND); + + if moderation.context_menu { + if_enabled!(config, warn, value, CONTEXT_MENUS); + if_enabled!(config, kick, value, CONTEXT_MENUS); + if_enabled!(config, ban, value, CONTEXT_MENUS); + } + + if_enabled!(config, clear, value, CLEAR_COMMAND); + } + + if_enabled!(config, case_last, value, CASE_LAST_COMMAND); + if_enabled!(config, case_list, value, CASE_LIST_COMMAND); + if_enabled!(config, case_details, value, CASE_DETAILS_COMMAND); + if_enabled!(config, case_remove, value, CASE_REMOVE_COMMAND); + if_enabled!(config, case_edit, value, CASE_EDIT_COMMAND); + + if let Some(top) = &config.top { + if top.day { + if_enabled!(config, top_day_me, value, TOP_DAY_ME_COMMAND); + if_enabled!(config, top_day_all, value, TOP_DAY_ALL_COMMAND); + } + + if top.week { + if_enabled!(config, top_week_me, value, TOP_WEEK_ME_COMMAND); + if_enabled!(config, top_week_all, value, TOP_WEEK_ALL_COMMAND); + } + } + + value +} + +#[cfg(test)] +mod tests { + use crate::server::guild::commands::bitfield::get_enabled_bitfield; + use crate::utils::config::create_debug_config; + + #[test] + fn test_is_equal() { + let config1 = create_debug_config(); + let mut config2 = create_debug_config(); + assert_eq!(get_enabled_bitfield(&config1), get_enabled_bitfield(&config2)); + + config2.enabled_commands.ban = false; + assert_ne!(get_enabled_bitfield(&config1), get_enabled_bitfield(&config2)); + } +} \ No newline at end of file diff --git a/src/server/guild/commands/mod.rs b/src/server/guild/commands/mod.rs index 2f9d8e3..89acc95 100644 --- a/src/server/guild/commands/mod.rs +++ b/src/server/guild/commands/mod.rs @@ -1,6 +1,7 @@ mod activity; mod cases; mod moderation; +pub mod bitfield; use crate::models::config::GuildConfig; use twilight_model::application::command::{ From efc7fef4ba600c44efe540a979eb565d7b7b3de4 Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:01:20 +0200 Subject: [PATCH 7/8] add ability to set last synced commands --- src/database/redis.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/database/redis.rs b/src/database/redis.rs index 66b82f9..592c478 100644 --- a/src/database/redis.rs +++ b/src/database/redis.rs @@ -12,6 +12,8 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::error::SendError; use tracing::info; use crate::database::mongodb::MongoDBConnection; +use crate::models::config::GuildConfig; +use crate::server::guild::commands::bitfield::get_enabled_bitfield; #[derive(Serialize, Deserialize, Debug)] pub struct PartialGuild { @@ -124,6 +126,26 @@ impl RedisConnection { connection.zincr(path, user_id.to_string(), count).await } + pub async fn set_commands_as_synced(&self, config: &GuildConfig) -> Result<(), RedisError> { + let mut connection = connection!(self)?; + let bitfield = get_enabled_bitfield(&config); + let _: () = connection + .set( + format!("commands.{}", config.guild_id), + bitfield.to_string() + ).await?; + + Ok(()) + } + + pub async fn are_commands_synced(&self, config: &GuildConfig) -> Result { + let mut connection = connection!(self)?; + let bitfield = get_enabled_bitfield(&config); + let response: Option = connection.get(format!("commands.{}", config.guild_id)).await?; + + Ok(response.map_or_else(|| false, |v| v == bitfield.to_string())) + } + pub async fn watch_config_updates( &self, mongodb: &MongoDBConnection From 0b95ab1bd3af602deb261e2048a3ee3adc91cf5c Mon Sep 17 00:00:00 2001 From: ocean <47253870+banocean@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:08:37 +0200 Subject: [PATCH 8/8] is_synced value and SynchronizeCommands action --- src/context.rs | 6 ++- src/main.rs | 49 ++++++++++++----------- src/models/config/mod.rs | 3 -- src/server/guild/commands/activity.rs | 2 +- src/server/guild/editing.rs | 52 ++++++++++++++++++++++--- src/server/guild/ws.rs | 56 +++++++++++++++++++++------ src/server/mod.rs | 4 +- src/server/routes/guilds/_id.rs | 21 +++++++++- src/server/routes/mod.rs | 14 +++++-- 9 files changed, 156 insertions(+), 51 deletions(-) diff --git a/src/context.rs b/src/context.rs index f228163..3749749 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,3 +1,5 @@ +use twilight_model::id::Id; +use twilight_model::id::marker::ApplicationMarker; use crate::{ all_macro, env_unwrap, @@ -19,10 +21,11 @@ pub struct Context { pub scam_domains: ScamLinks, #[cfg(feature = "gateway")] pub bucket: Bucket, + pub application_id: Id, } impl Context { - pub async fn new() -> Self { + pub async fn new(application_id: Id) -> Self { let mongodb_uri = env_unwrap!("MONGODB_URI"); let redis_url = env_unwrap!("REDIS_URL"); @@ -49,6 +52,7 @@ impl Context { #[cfg(feature = "gateway")] bucket, application, + application_id } } } diff --git a/src/main.rs b/src/main.rs index f47f040..663967e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use dotenv::dotenv; use tokio::task::JoinHandle; use tracing::info; use twilight_http::Client; +use crate::gateway::clients::{DiscordClients, LoadDiscordClients}; all_macro!( cfg(feature = "gateway"); @@ -34,36 +35,38 @@ async fn main() { tracing_init::init(); info!("starting app"); - let context = Arc::new(Context::new().await); - + let discord_token = env_unwrap!("DISCORD_TOKEN"); + let main_http = Arc::new(Client::new(discord_token.to_owned())); + let current_user = main_http + .current_user() + .await + .unwrap() + .model() + .await + .unwrap(); + info!("current user fetched: {}#{}", current_user.name, current_user.discriminator); + let application_id = current_user.id.cast(); + + let context = Arc::new(Context::new(application_id).await); let cloned_context = context.clone(); tokio::spawn(async move { let context = cloned_context; context.redis.watch_config_updates(&context.mongodb).await.unwrap(); }); - let discord_token = env_unwrap!("DISCORD_TOKEN"); - let main_http = Arc::new(Client::new(discord_token.to_owned())); - let mut threads: Vec> = vec![]; - #[cfg(any(feature = "custom-clients", feature = "tasks"))] - { - use crate::gateway::clients::{DiscordClients, LoadDiscordClients}; - let discord_clients = DiscordClients::load(&context.mongodb).await.unwrap(); - - #[cfg(feature = "tasks")] - { - threads.push(tasks::run( - context.mongodb.to_owned(), - discord_clients.to_owned(), - main_http.to_owned() - )); - } - - #[cfg(feature = "custom-clients")] - threads.append(&mut discord_clients.start(context.to_owned())); - } + let discord_clients = DiscordClients::load(&context.mongodb).await.unwrap(); + + #[cfg(feature = "tasks")] + threads.push(tasks::run( + context.mongodb.to_owned(), + discord_clients.to_owned(), + main_http.to_owned() + )); + + #[cfg(any(feature = "custom-clients"))] + threads.append(&mut discord_clients.start(context.to_owned())); #[cfg(feature = "gateway")] { @@ -95,7 +98,7 @@ async fn main() { }; let run = tokio::spawn(crate::server::listen( - 80, context, main_http, #[cfg(feature = "http-interactions")] public_key + 80, context, main_http, discord_clients, #[cfg(feature = "http-interactions")] public_key )); threads.push(run); } diff --git a/src/models/config/mod.rs b/src/models/config/mod.rs index f716ee3..dea8810 100644 --- a/src/models/config/mod.rs +++ b/src/models/config/mod.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; -use std::sync::Arc; use serde::{Serialize, Deserialize}; use twilight_model::id::Id; use twilight_model::id::marker::{ApplicationMarker, GuildMarker}; @@ -19,7 +17,6 @@ pub struct GuildConfig { pub guild_id: Id, pub application_id: Option>, pub enabled_commands: Commands, - pub moderation: Option, pub premium: bool, pub levels: Option, diff --git a/src/server/guild/commands/activity.rs b/src/server/guild/commands/activity.rs index 6a6306d..7c3be2e 100644 --- a/src/server/guild/commands/activity.rs +++ b/src/server/guild/commands/activity.rs @@ -1,5 +1,5 @@ use crate::models::config::GuildConfig; -use crate::server::guild::commands::{default_option, defaults_command, if_enabled, options}; +use crate::server::guild::commands::{default_option, defaults_command, if_enabled}; use twilight_model::application::command::{Command, CommandOption, CommandOptionType}; macro_rules! subcommand { diff --git a/src/server/guild/editing.rs b/src/server/guild/editing.rs index d8b2000..c376c03 100644 --- a/src/server/guild/editing.rs +++ b/src/server/guild/editing.rs @@ -9,7 +9,9 @@ use tracing::{error, warn}; use twilight_model::id::Id; use twilight_model::id::marker::{GuildMarker, UserMarker}; use crate::context::Context; +use crate::gateway::clients::DiscordClients; use crate::models::config::GuildConfig; +use crate::server::guild::commands::get_guild_commands_list; use crate::server::guild::ws::{Connection, OutboundAction, OutboundMessage}; #[derive(Clone, Debug, Serialize)] @@ -130,7 +132,12 @@ impl GuildsEditing { Some((config.to_owned(), guild_lock.changes.to_owned(), users)) } - pub async fn broadcast_config_overwrite(&self, context: &Arc, guild_id: Id) -> Option<()> { + pub async fn broadcast_config_overwrite( + &self, + context: &Arc, + guild_id: Id, + is_synced: bool + ) -> Option<()> { let config = context.mongodb .get_config(guild_id) .await @@ -139,21 +146,19 @@ impl GuildsEditing { let guild = self.get_guild(guild_id).await?; let guild_lock = guild.lock().await; - let users = guild_lock.connections - .iter().map(|connection| connection.user_id) - .collect::>>(); for connection in &guild_lock.connections { let _ = connection.tx.send(OutboundAction::Message(OutboundMessage::OverwriteConfigurationData { saved_config: config.to_owned(), changes: guild_lock.changes.to_owned(), + is_synced })); } Some(()) } - pub async fn apply_changes(&self, context: &Arc, guild_id: Id) -> Option<()> { + pub async fn apply_changes(&self, context: &Arc, guild_id: Id) -> Option { let config = context.mongodb.get_config(guild_id).await .inspect_err(|error| error!(name: "mongodb error", ?error)) .ok()?; @@ -186,6 +191,10 @@ impl GuildsEditing { return None } + let is_synced = context.redis.are_commands_synced(&new_config).await + .inspect_err(|error| error!(name: "redis error", ?error)) + .unwrap_or_else(|_| true); + context.mongodb.configs .replace_one( doc! { "guild_id": guild_id.to_string() }, @@ -199,6 +208,37 @@ impl GuildsEditing { guild_lock.changes = vec![]; - Some(()) + Some(is_synced) + } + + pub async fn register_commands( + &self, + context: &Arc, + discord_http: &Arc, + discord_clients: &DiscordClients, + guild_id: Id + ) -> Option { + let config = context.mongodb.get_config(guild_id).await + .inspect_err(|error| error!(name: "mongodb error", ?error)) + .ok()?; + + let guild_discord_http = config.application_id + .and_then(|id| { + discord_clients.get(&id).map(|option| option.value().clone()) + }) + .unwrap_or_else(|| discord_http.clone()); + let application_id = config.application_id.unwrap_or_else(|| context.application_id); + + let commands = get_guild_commands_list(&config); + guild_discord_http + .interaction(application_id) + .set_guild_commands(guild_id, &commands) + .await + .inspect_err(|error| { + error!(name: "error setting guild commands", ?error, %guild_id, %application_id) + }) + .ok()?; + + Some(config) } } diff --git a/src/server/guild/ws.rs b/src/server/guild/ws.rs index 5c3b1fe..64367ed 100644 --- a/src/server/guild/ws.rs +++ b/src/server/guild/ws.rs @@ -13,6 +13,7 @@ use twilight_model::user::CurrentUserGuild; use warp::ws::{Message, WebSocket}; use crate::context::Context; use crate::database::redis::PartialGuild; +use crate::gateway::clients::DiscordClients; use crate::models::config::GuildConfig; use crate::ok_or_return; use crate::server::guild::editing::{Change, GuildsEditing}; @@ -32,7 +33,7 @@ macro_rules! unwrap_or_close_and_return { let reason = $reason; tracing::warn!(name: "connection closed due to error", ?err, ?reason); close!($tx, reason); - return + return None } } }; @@ -68,6 +69,8 @@ pub struct Connection { pub async fn handle_connection( context: Arc, + discord_http: Arc, + discord_clients: DiscordClients, ws: WebSocket, info: Arc, guild: CurrentUserGuild, @@ -84,10 +87,8 @@ pub async fn handle_connection( let guild_id = guild.id; let guilds_editing_clone = guilds_editing.clone(); - let context_clone = context.clone(); tokio::spawn(async move { let guilds_editing = guilds_editing_clone; - let context = context_clone; while let Some(message) = rx.next().await { match message { OutboundAction::Message(msg) => { @@ -142,7 +143,16 @@ pub async fn handle_connection( break } - on_message(message, &info, &guild, &tx, &guilds_editing, &context).await; + on_message( + message, + &info, + &guild, + &tx, + &guilds_editing, + &context, + &discord_http, + &discord_clients + ).await; } guilds_editing.remove_connection(guild_id, session_id).await; @@ -152,7 +162,8 @@ pub async fn handle_connection( #[serde(tag = "action", content = "data")] enum InboundMessage { GuildConfigUpdate(Patch), - ApplyChanges + SynchronizeCommands, + ApplyChanges, } #[derive(Debug, Serialize)] @@ -168,9 +179,11 @@ pub enum OutboundMessage { }, OverwriteConfigurationData { saved_config: GuildConfig, - changes: Vec + changes: Vec, + is_synced: bool }, OverwriteUsers(Vec>), + OverwriteIsSynced(bool), PushChange(Change) } @@ -185,8 +198,10 @@ async fn on_message( guild: &CurrentUserGuild, tx: &UnboundedSender, guilds_editing: &Arc, - context: &Arc -) { + context: &Arc, + discord_http: &Arc, + discord_clients: &DiscordClients +) -> Option<()> { let message = unwrap_or_close_and_return!( message.to_str(), tx, CloseReason::MessageIsNotString ); @@ -197,8 +212,8 @@ async fn on_message( match message { InboundMessage::GuildConfigUpdate(changes) => { - let _ = guilds_editing.marge_changes(info.user.id, guild.id, changes.to_owned()).await; - let _ = guilds_editing.broadcast_change(guild.id, info.user.id, changes).await; + guilds_editing.marge_changes(info.user.id, guild.id, changes.to_owned()).await?; + guilds_editing.broadcast_change(guild.id, info.user.id, changes).await?; } InboundMessage::ApplyChanges => { info!( @@ -206,12 +221,29 @@ async fn on_message( author_id = %info.user.id, guild_id = %guild.id ); - guilds_editing.apply_changes(&context, guild.id).await; - let _ = guilds_editing.broadcast_config_overwrite(&context, guild.id).await; + let is_synced = guilds_editing.apply_changes(&context, guild.id).await?; + let _ = guilds_editing.broadcast_config_overwrite(&context, guild.id, is_synced).await; let _ = context.redis.announce_config_update(guild.id).await .inspect_err(|error| { error!(name: "error sending guild_id to redis update announcer", ?error, %guild.id) }); } + InboundMessage::SynchronizeCommands => { + info!( + name: "synchronizing commands", + author_id = %info.user.id, + guild_id = %guild.id + ); + let config = guilds_editing + .register_commands(&context, &discord_http, &discord_clients, guild.id) + .await?; + let _ = context.redis + .set_commands_as_synced(&config) + .await + .inspect_err(|err| error!(name: "redis error while updating commands bitfield", ?err)); + let _ = tx.send(OutboundAction::Message(OutboundMessage::OverwriteIsSynced(true))); + } } + + Some(()) } diff --git a/src/server/mod.rs b/src/server/mod.rs index 1c0b79f..49c6ace 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -41,6 +41,7 @@ mod http_server { use warp::Filter; use warp::http::{HeaderName, Method}; use crate::context::Context; + use crate::gateway::clients::DiscordClients; #[macro_export] macro_rules! with_value { @@ -60,6 +61,7 @@ mod http_server { port: u16, context: Arc, discord_http: Arc, + discord_clients: DiscordClients, #[cfg(feature = "http-interactions")] public_key: ed25519_dalek::VerifyingKey ) { let cors_allow = if let Ok(origin) = env::var("ALLOWED_ORIGIN") { @@ -81,7 +83,7 @@ mod http_server { .build(); let routes = crate::server::routes::get_all_routes( - discord_http, context, #[cfg(feature = "http-interactions")] public_key + context, discord_http, discord_clients, #[cfg(feature = "http-interactions")] public_key ) .recover(crate::server::error::handle_rejection) .with(cors_allow); diff --git a/src/server/routes/guilds/_id.rs b/src/server/routes/guilds/_id.rs index 133a166..b547a1e 100644 --- a/src/server/routes/guilds/_id.rs +++ b/src/server/routes/guilds/_id.rs @@ -9,6 +9,7 @@ use warp::ws::Ws; use crate::{response_type, with_value}; use crate::context::Context; +use crate::gateway::clients::DiscordClients; use crate::server::error::{MapErrorIntoInternalRejection, Rejection}; use crate::server::guild::editing::GuildsEditing; use crate::server::guild::ws::handle_connection; @@ -18,10 +19,14 @@ type GuildId = Id; pub fn run( context: Arc, + discord_http: Arc, + discord_clients: DiscordClients, authenticator: Arc, sessions: Arc ) -> response_type!() { let with_context = with_value!(context); + let with_discord_http = with_value!(discord_http); + let with_clients = with_value!(discord_clients); let guilds_editing = Arc::new(GuildsEditing::default()); let with_guilds_editing = with_value!(guilds_editing); @@ -32,17 +37,31 @@ pub fn run( .and_then(check_guild) .and(warp::ws()) .and(with_context) + .and(with_discord_http) + .and(with_clients) .and(with_guilds_editing) .map(| (info, guild): (Arc, CurrentUserGuild), ws: Ws, context: Arc, + discord_http: Arc, + discord_clients: DiscordClients, guilds_editing: Arc | { let context = context.clone(); + let discord_clients = discord_clients.clone(); + let discord_http = discord_http.clone(); let guilds_editing = guilds_editing.clone(); ws.on_upgrade(move |ws| { - handle_connection(context, ws, info, guild, guilds_editing) + handle_connection( + context, + discord_http, + discord_clients, + ws, + info, + guild, + guilds_editing + ) }) }) } diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs index 0b7d827..627be0e 100644 --- a/src/server/routes/mod.rs +++ b/src/server/routes/mod.rs @@ -4,6 +4,7 @@ use warp::Filter; use warp::http::StatusCode; use crate::context::Context; use crate::{all_macro, response_type}; +use crate::gateway::clients::DiscordClients; #[cfg(feature = "http-interactions")] mod interactions; @@ -20,8 +21,9 @@ mod users { } pub fn get_all_routes( - discord_http: Arc, context: Arc, + discord_http: Arc, + discord_clients: DiscordClients, #[cfg(feature = "http-interactions")] public_key: ed25519_dalek::VerifyingKey ) -> response_type!() { let filter = warp::path::end().map(|| { @@ -30,7 +32,7 @@ pub fn get_all_routes( #[cfg(feature = "http-interactions")] let filter = filter.or(interactions::filter( - discord_http, context.to_owned(), public_key + discord_http.clone(), context.to_owned(), public_key )); #[cfg(feature = "api")] @@ -47,7 +49,13 @@ pub fn get_all_routes( let filter = filter .or(login::login(authenticator.to_owned(), sessions.to_owned())) .or(users::me::run(authenticator.to_owned(), sessions.to_owned())) - .or(guilds::_id::run(context.to_owned(), authenticator.to_owned(), sessions.to_owned())) + .or(guilds::_id::run( + context.to_owned(), + discord_http, + discord_clients, + authenticator.to_owned(), + sessions.to_owned() + )) .or(guilds::list(context, authenticator, sessions)) .with(tracing);