diff --git a/src/commands/automod_service.rs b/src/commands/automod_service.rs new file mode 100644 index 0000000..22b3c34 --- /dev/null +++ b/src/commands/automod_service.rs @@ -0,0 +1,602 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::send_embed; +use crate::commands::moderation_sanction_helpers::{add_sanction, handle_timeout}; +use crate::db::{ + self, DbPoolKey, ModerationSettings, PunishRule, count_member_strikes_in_window, + ensure_default_punish_rules, get_last_punish_triggered_at, upsert_last_punish_triggered_at, +}; +use crate::permissions; + +static SPAM_TRACKER: OnceLock>>> = OnceLock::new(); + +pub async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +pub fn parse_on_off(input: &str) -> Option { + match input.trim().to_lowercase().as_str() { + "on" | "enable" | "enabled" | "true" | "1" => Some(true), + "off" | "disable" | "disabled" | "false" | "0" => Some(false), + _ => None, + } +} + +pub fn parse_duration_to_seconds(input: &str) -> Option { + let raw = input.trim().to_lowercase(); + if raw.is_empty() { + return None; + } + + let mut digits = String::new(); + let mut suffix = String::new(); + + for ch in raw.chars() { + if ch.is_ascii_digit() { + if !suffix.is_empty() { + return None; + } + digits.push(ch); + } else if !ch.is_whitespace() { + suffix.push(ch); + } + } + + let value = digits.parse::().ok()?; + if value <= 0 { + return None; + } + + let unit = if suffix.is_empty() { "s" } else { &suffix }; + let seconds = match unit { + "s" | "sec" | "secs" | "seconde" | "secondes" => value, + "m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60)?, + "h" | "heure" | "heures" => value.checked_mul(3_600)?, + "j" | "d" | "jour" | "jours" => value.checked_mul(86_400)?, + "w" | "sem" | "semaine" | "semaines" => value.checked_mul(604_800)?, + _ => return None, + }; + + Some(seconds.max(1)) +} + +pub fn format_duration(mut seconds: i64) -> String { + seconds = seconds.max(1); + let days = seconds / 86_400; + seconds %= 86_400; + let hours = seconds / 3_600; + seconds %= 3_600; + let minutes = seconds / 60; + seconds %= 60; + + let mut out = Vec::new(); + if days > 0 { + out.push(format!("{}j", days)); + } + if hours > 0 { + out.push(format!("{}h", hours)); + } + if minutes > 0 { + out.push(format!("{}m", minutes)); + } + if seconds > 0 || out.is_empty() { + out.push(format!("{}s", seconds)); + } + + out.join(" ") +} + +pub fn parse_rate_limit(input: &str) -> Option<(i32, i32)> { + let mut parts = input.splitn(2, '/'); + let limit = parts.next()?.trim().parse::().ok()?.max(1); + let duration = parse_duration_to_seconds(parts.next()?.trim())?; + if duration > i32::MAX as i64 { + return None; + } + + Some((limit, duration as i32)) +} + +pub fn parse_trigger(input: &str) -> Option<&'static str> { + match input.trim().to_lowercase().as_str() { + "spam" | "antispam" => Some("spam"), + "link" | "antilink" => Some("link"), + "massmention" | "antimassmention" | "mention" | "mentions" => Some("massmention"), + "badword" | "badwords" | "mauvaismot" | "motinterdit" => Some("badword"), + _ => None, + } +} + +pub fn parse_profile(input: Option<&str>) -> Option<&'static str> { + let raw = input?.trim().to_lowercase(); + match raw.as_str() { + "ancien" | "old" => Some("old"), + "nouveau" | "new" => Some("new"), + _ => None, + } +} + +pub fn parse_sanction(input: &str) -> Option<&'static str> { + match input.trim().to_lowercase().as_str() { + "warn" | "avert" => Some("warn"), + "mute" | "timeout" => Some("mute"), + "kick" => Some("kick"), + "ban" => Some("ban"), + _ => None, + } +} + +pub fn apply_channel_override(global_enabled: bool, override_mode: Option<&str>) -> bool { + match override_mode { + Some(mode) if mode.eq_ignore_ascii_case("allow") => false, + Some(mode) if mode.eq_ignore_ascii_case("deny") => true, + _ => global_enabled, + } +} + +fn contains_invite_link(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("discord.gg/") + || lower.contains("discord.com/invite/") + || lower.contains("discordapp.com/invite/") +} + +fn contains_any_link(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("http://") + || lower.contains("https://") + || lower.contains("www.") + || lower.contains("discord.gg/") +} + +fn spam_hit(bot_id: u64, guild_id: u64, user_id: u64, limit: i32, window_seconds: i32) -> bool { + let lock = SPAM_TRACKER.get_or_init(|| Mutex::new(HashMap::new())); + let mut tracker = lock.lock().expect("spam tracker lock poisoned"); + + let key = (bot_id, guild_id, user_id); + let now = Instant::now(); + let window = Duration::from_secs(window_seconds.max(1) as u64); + let queue = tracker.entry(key).or_insert_with(VecDeque::new); + + queue.push_back(now); + + while let Some(oldest) = queue.front() { + if now.duration_since(*oldest) > window { + let _ = queue.pop_front(); + } else { + break; + } + } + + queue.len() > limit.max(1) as usize +} + +async fn user_profile( + ctx: &Context, + pool: &sqlx::PgPool, + bot_id: i64, + guild_id: GuildId, + user_id: UserId, +) -> &'static str { + let Ok(old_settings) = + db::get_or_create_old_member_settings(pool, bot_id, guild_id.get() as i64).await + else { + return "new"; + }; + + if !old_settings.enabled { + return "new"; + } + + let Some(role_id_raw) = old_settings.role_id else { + return "new"; + }; + + let Ok(member) = guild_id.member(&ctx.http, user_id).await else { + return "new"; + }; + + if member + .roles + .iter() + .any(|role_id| role_id.get() as i64 == role_id_raw) + { + "old" + } else { + "new" + } +} + +async fn execute_rule( + ctx: &Context, + guild_id: GuildId, + user_id: UserId, + rule: &PunishRule, + settings: &ModerationSettings, +) -> String { + let sanction = rule.sanction.to_lowercase(); + let bot_user_id = ctx.cache.current_user().id; + + if sanction == "warn" { + add_sanction( + ctx, + guild_id, + user_id, + bot_user_id, + "warn", + "AutoMod: seuil de strikes atteint.", + None, + None, + ) + .await; + return "warn".to_string(); + } + + if sanction == "mute" || sanction == "timeout" { + let duration = rule + .sanction_seconds + .unwrap_or(3_600) + .clamp(1, 28 * 24 * 3_600); + let expires = Some(Utc::now() + chrono::Duration::seconds(duration)); + let _ = handle_timeout(ctx, guild_id, &[user_id], expires).await; + add_sanction( + ctx, + guild_id, + user_id, + bot_user_id, + "tempmute", + "AutoMod: seuil de strikes atteint.", + None, + expires, + ) + .await; + if settings.use_timeout { + return format!("timeout {}", format_duration(duration)); + } + return format!("mute role {}", format_duration(duration)); + } + + if sanction == "kick" { + let result = guild_id + .kick_with_reason(&ctx.http, user_id, "AutoMod: seuil de strikes atteint") + .await; + + if result.is_ok() { + add_sanction( + ctx, + guild_id, + user_id, + bot_user_id, + "kick", + "AutoMod: seuil de strikes atteint.", + None, + None, + ) + .await; + return "kick".to_string(); + } + + return "kick (echec)".to_string(); + } + + if sanction == "ban" { + let result = guild_id + .ban_with_reason(&ctx.http, user_id, 0, "AutoMod: seuil de strikes atteint") + .await; + + if result.is_ok() { + add_sanction( + ctx, + guild_id, + user_id, + bot_user_id, + "ban", + "AutoMod: seuil de strikes atteint.", + None, + None, + ) + .await; + return "ban".to_string(); + } + + return "ban (echec)".to_string(); + } + + "aucune".to_string() +} + +async fn apply_violation( + ctx: &Context, + msg: &Message, + pool: &sqlx::PgPool, + settings: &ModerationSettings, + trigger: &str, + reason: &str, +) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let _ = msg.delete(&ctx.http).await; + + let bot_id = settings.bot_id; + let guild_id_raw = settings.guild_id; + let user_id = msg.author.id; + let profile = user_profile(ctx, pool, bot_id, guild_id, user_id).await; + + let strikes = db::get_strike_rule(pool, bot_id, guild_id_raw, trigger, profile) + .await + .ok() + .flatten() + .unwrap_or(1) + .max(0); + + if strikes > 0 { + let _ = db::add_member_strike_event( + pool, + bot_id, + guild_id_raw, + user_id.get() as i64, + trigger, + strikes, + ) + .await; + } + + let _ = ensure_default_punish_rules(pool, bot_id, guild_id_raw).await; + let rules = db::list_punish_rules(pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + + let mut action = String::from("aucune"); + for rule in rules.iter().rev() { + let Ok(total) = count_member_strikes_in_window( + pool, + bot_id, + guild_id_raw, + user_id.get() as i64, + rule.window_seconds, + ) + .await + else { + continue; + }; + + if total < rule.threshold as i64 { + continue; + } + + let recent_trigger = + get_last_punish_triggered_at(pool, bot_id, guild_id_raw, user_id.get() as i64, rule.id) + .await + .ok() + .flatten() + .map(|at| Utc::now() - at < chrono::Duration::seconds(rule.window_seconds)) + .unwrap_or(false); + + if recent_trigger { + continue; + } + + action = execute_rule(ctx, guild_id, user_id, rule, settings).await; + let _ = upsert_last_punish_triggered_at( + pool, + bot_id, + guild_id_raw, + user_id.get() as i64, + rule.id, + ) + .await; + break; + } + + let embed = CreateEmbed::new() + .title("AutoMod") + .description(format!( + "{}\nMembre: <@{}>\nTrigger: `{}` ยท Profil: `{}` ยท Strikes: `+{}`\nAction: `{}`", + reason, + user_id.get(), + trigger, + profile, + strikes, + action + )) + .color(0xED4245); + send_embed(ctx, msg, embed).await; +} + +pub async fn enforce_automod_message(ctx: &Context, msg: &Message) -> bool { + let Some(guild_id) = msg.guild_id else { + return false; + }; + + let Some(pool) = pool(ctx).await else { + return false; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let settings = + match db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await { + Ok(settings) => settings, + Err(_) => return false, + }; + + let channel_id = msg.channel_id.get() as i64; + let spam_override = db::get_moderation_channel_override( + &pool, + bot_id, + guild_id.get() as i64, + channel_id, + "spam", + ) + .await + .ok() + .flatten(); + let link_override = db::get_moderation_channel_override( + &pool, + bot_id, + guild_id.get() as i64, + channel_id, + "link", + ) + .await + .ok() + .flatten(); + + let antispam_enabled = + apply_channel_override(settings.antispam_enabled, spam_override.as_deref()); + let antilink_enabled = + apply_channel_override(settings.antilink_enabled, link_override.as_deref()); + + if settings.badwords_enabled { + let content = msg.content.to_lowercase(); + let badwords = db::list_badwords(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or_default(); + if badwords + .iter() + .any(|word| !word.is_empty() && content.contains(word)) + { + apply_violation( + ctx, + msg, + &pool, + &settings, + "badword", + "Message supprime: mot interdit detecte.", + ) + .await; + return true; + } + } + + if settings.antimassmention_enabled { + let mention_count = msg.mentions.len() + msg.mention_roles.len(); + if mention_count >= settings.antimassmention_limit.max(1) as usize { + apply_violation( + ctx, + msg, + &pool, + &settings, + "massmention", + "Message supprime: spam de mentions detecte.", + ) + .await; + return true; + } + } + + if antilink_enabled { + let link_hit = if settings.antilink_mode.eq_ignore_ascii_case("all") { + contains_any_link(&msg.content) + } else { + contains_invite_link(&msg.content) + }; + + if link_hit { + apply_violation( + ctx, + msg, + &pool, + &settings, + "link", + "Message supprime: lien interdit detecte.", + ) + .await; + return true; + } + } + + if antispam_enabled { + let hit = spam_hit( + ctx.cache.current_user().id.get(), + guild_id.get(), + msg.author.id.get(), + settings.antispam_limit.max(1), + settings.antispam_window_seconds.max(1), + ); + + if hit { + apply_violation( + ctx, + msg, + &pool, + &settings, + "spam", + "Message supprime: spam detecte.", + ) + .await; + return true; + } + } + + false +} + +pub async fn public_command_allowed( + ctx: &Context, + msg: &Message, + command_key: &str, + required_permission: u8, +) -> bool { + if permissions::is_owner_user(ctx, msg.author.id).await { + return true; + } + + if required_permission > 0 { + return true; + } + + let Some(guild_id) = msg.guild_id else { + return true; + }; + + let Some(pool) = pool(ctx).await else { + return true; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let settings = + match db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await { + Ok(settings) => settings, + Err(_) => return true, + }; + + let override_mode = db::get_moderation_channel_override( + &pool, + bot_id, + guild_id.get() as i64, + msg.channel_id.get() as i64, + "public", + ) + .await + .ok() + .flatten(); + + let allowed = match override_mode.as_deref() { + Some(mode) if mode.eq_ignore_ascii_case("allow") => true, + Some(mode) if mode.eq_ignore_ascii_case("deny") => false, + _ => settings.public_commands_enabled, + }; + if allowed { + return true; + } + + let embed = CreateEmbed::new() + .title("Commandes publiques desactivees") + .description(format!( + "La commande `{}` est desactivee dans ce salon.", + command_key.replace('_', " ") + )) + .color(0xED4245); + send_embed(ctx, msg, embed).await; + false +} diff --git a/src/commands/logs/autoconfiglog.rs b/src/commands/logs/autoconfiglog.rs index 18484c8..2f9a32a 100644 --- a/src/commands/logs/autoconfiglog.rs +++ b/src/commands/logs/autoconfiglog.rs @@ -11,7 +11,15 @@ use serenity::prelude::*; use crate::commands::common::{send_embed, theme_color}; use crate::commands::logs_command_helpers::{pool, set_log_channel}; -const LOG_TYPES: &[&str] = &["moderation", "message", "voice", "boost", "role", "raid", "channel"]; +const LOG_TYPES: &[&str] = &[ + "moderation", + "message", + "voice", + "boost", + "role", + "raid", + "channel", +]; const LOG_CATEGORY_NAME: &str = "๐Ÿ“ โžœ Espace Logs"; const LOG_CHANNEL_PREFIX: &str = "๐Ÿ“ใƒป"; const AUTOCONFIGLOG_COMPONENT_PREFIX: &str = "autoconfiglog"; diff --git a/src/commands/logs_service.rs b/src/commands/logs_service.rs index 3b210ae..6ce44ed 100644 --- a/src/commands/logs_service.rs +++ b/src/commands/logs_service.rs @@ -125,7 +125,10 @@ pub async fn emit_log( embed = embed.timestamp(timestamp); - record_audit_log(ctx, guild_id, log_type, user_id, channel_id, role_id, action).await; + record_audit_log( + ctx, guild_id, log_type, user_id, channel_id, role_id, action, + ) + .await; if let Some(log_channel_id) = get_log_channel(ctx, guild_id, log_type).await { let _ = log_channel_id diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 09bd188..e2c5aec 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -13,16 +13,29 @@ pub mod alladmins; pub mod allbots; #[path = "permissions/allperms.rs"] pub mod allperms; +#[path = "roles/ancien.rs"] +pub mod ancien; +#[path = "moderation/antilink.rs"] +pub mod antilink; +#[path = "moderation/antimassmention.rs"] +pub mod antimassmention; +#[path = "moderation/antiraideautoconfig.rs"] +pub mod antiraideautoconfig; +#[path = "moderation/antispam.rs"] +pub mod antispam; #[path = "outils/autobackup.rs"] pub mod autobackup; #[path = "logs/autoconfiglog.rs"] pub mod autoconfiglog; +pub mod automod_service; #[path = "outils/autopublish.rs"] pub mod autopublish; #[path = "outils/autoreact.rs"] pub mod autoreact; #[path = "outils/backup.rs"] pub mod backup; +#[path = "moderation/badwords.rs"] +pub mod badwords; #[path = "moderation/ban.rs"] pub mod ban; #[path = "moderation/banlist.rs"] @@ -63,8 +76,12 @@ pub mod claim; pub mod cleanup; #[path = "moderation/clear_all_sanctions.rs"] pub mod clear_all_sanctions; +#[path = "moderation/clear_badwords.rs"] +pub mod clear_badwords; #[path = "administration/clear_bl.rs"] pub mod clear_bl; +#[path = "moderation/clear_limit.rs"] +pub mod clear_limit; #[path = "moderation/clear_messages.rs"] pub mod clear_messages; #[path = "administration/clear_owners.rs"] @@ -125,6 +142,8 @@ pub mod kick; pub mod leave; #[path = "logs/leave_settings.rs"] pub mod leave_settings; +#[path = "moderation/link.rs"] +pub mod link; #[path = "bot/listen.rs"] pub mod listen; #[path = "outils/loading.rs"] @@ -154,8 +173,12 @@ pub mod mp; pub mod mute; #[path = "moderation/mutelist.rs"] pub mod mutelist; +#[path = "moderation/muterole.rs"] +pub mod muterole; #[path = "outils/newsticker.rs"] pub mod newsticker; +#[path = "roles/noderank.rs"] +pub mod noderank; #[path = "logs/nolog.rs"] pub mod nolog; #[path = "bot/online.rs"] @@ -168,12 +191,18 @@ pub mod perms_helpers; pub mod perms_service; #[path = "infos/pic.rs"] pub mod pic; +#[path = "outils/piconly.rs"] +pub mod piconly; #[path = "infos/ping.rs"] pub mod ping; #[path = "bot/playto.rs"] pub mod playto; #[path = "administration/prefix.rs"] pub mod prefix; +#[path = "moderation/public.rs"] +pub mod public; +#[path = "moderation/punish.rs"] +pub mod punish; #[path = "logs/raidlog.rs"] pub mod raidlog; #[path = "bot/remove_activity.rs"] @@ -184,6 +213,8 @@ pub mod rename; pub mod renew; #[path = "outils/reroll.rs"] pub mod reroll; +#[path = "moderation/resetantiraide.rs"] +pub mod resetantiraide; #[path = "infos/role.rs"] pub mod role; #[path = "logs/rolelog.rs"] @@ -206,6 +237,8 @@ pub mod set; pub mod set_boostembed; #[path = "logs/set_modlogs.rs"] pub mod set_modlogs; +#[path = "moderation/set_muterole.rs"] +pub mod set_muterole; #[path = "bot/shadowbot.rs"] pub mod shadowbot; #[path = "infos/showpics.rs"] @@ -214,8 +247,12 @@ pub mod showpics; pub mod slowmode; #[path = "outils/snipe.rs"] pub mod snipe; +#[path = "moderation/spam.rs"] +pub mod spam; #[path = "bot/stream.rs"] pub mod stream; +#[path = "moderation/strikes.rs"] +pub mod strikes; #[path = "outils/suggestion.rs"] pub mod suggestion; #[path = "roles/sync.rs"] @@ -240,6 +277,8 @@ pub mod ticket; pub mod ticket_member; #[path = "outils/tickets.rs"] pub mod tickets; +#[path = "moderation/timeout.rs"] +pub mod timeout; #[path = "moderation/unban.rs"] pub mod unban; #[path = "moderation/unbanall.rs"] @@ -286,9 +325,12 @@ pub mod watch; pub fn all_command_metadata() -> Vec { vec![ ping::COMMAND_DESCRIPTOR.metadata(), + timeout::COMMAND_DESCRIPTOR.metadata(), allbots::COMMAND_DESCRIPTOR.metadata(), alladmins::COMMAND_DESCRIPTOR.metadata(), botadmins::COMMAND_DESCRIPTOR.metadata(), + ancien::COMMAND_DESCRIPTOR.metadata(), + antiraideautoconfig::COMMAND_DESCRIPTOR.metadata(), boosters::COMMAND_DESCRIPTOR.metadata(), rolemembers::COMMAND_DESCRIPTOR.metadata(), rolemenu::COMMAND_DESCRIPTOR.metadata(), @@ -322,6 +364,20 @@ pub fn all_command_metadata() -> Vec { choose::COMMAND_DESCRIPTOR.metadata(), embed::COMMAND_DESCRIPTOR.metadata(), clear_messages::COMMAND_DESCRIPTOR.metadata(), + clear_limit::COMMAND_DESCRIPTOR.metadata(), + clear_badwords::COMMAND_DESCRIPTOR.metadata(), + muterole::COMMAND_DESCRIPTOR.metadata(), + set_muterole::COMMAND_DESCRIPTOR.metadata(), + antispam::COMMAND_DESCRIPTOR.metadata(), + antilink::COMMAND_DESCRIPTOR.metadata(), + antimassmention::COMMAND_DESCRIPTOR.metadata(), + badwords::COMMAND_DESCRIPTOR.metadata(), + spam::COMMAND_DESCRIPTOR.metadata(), + link::COMMAND_DESCRIPTOR.metadata(), + strikes::COMMAND_DESCRIPTOR.metadata(), + punish::COMMAND_DESCRIPTOR.metadata(), + public::COMMAND_DESCRIPTOR.metadata(), + resetantiraide::COMMAND_DESCRIPTOR.metadata(), backup::COMMAND_DESCRIPTOR.metadata(), ticket::COMMAND_DESCRIPTOR.metadata(), claim::COMMAND_DESCRIPTOR.metadata(), @@ -330,6 +386,7 @@ pub fn all_command_metadata() -> Vec { close::COMMAND_DESCRIPTOR.metadata(), tickets::COMMAND_DESCRIPTOR.metadata(), showpics::COMMAND_DESCRIPTOR.metadata(), + piconly::COMMAND_DESCRIPTOR.metadata(), suggestion::COMMAND_DESCRIPTOR.metadata(), autopublish::COMMAND_DESCRIPTOR.metadata(), tempvoc::COMMAND_DESCRIPTOR.metadata(), @@ -372,6 +429,7 @@ pub fn all_command_metadata() -> Vec { addrole::COMMAND_DESCRIPTOR.metadata(), delrole::COMMAND_DESCRIPTOR.metadata(), derank::COMMAND_DESCRIPTOR.metadata(), + noderank::COMMAND_DESCRIPTOR.metadata(), del_sanction::COMMAND_DESCRIPTOR.metadata(), clear_sanctions::COMMAND_DESCRIPTOR.metadata(), clear_all_sanctions::COMMAND_DESCRIPTOR.metadata(), diff --git a/src/commands/moderation/antilink.rs b/src/commands/moderation/antilink.rs new file mode 100644 index 0000000..fd689a1 --- /dev/null +++ b/src/commands/moderation/antilink.rs @@ -0,0 +1,100 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_on_off, pool}; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_antilink(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(first) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiLink") + .description("Usage: +antilink | +antilink ") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + let Ok(current) = + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await + else { + return; + }; + + let updated = if let Some(value) = parse_on_off(first) { + db::set_antilink_settings( + &pool, + bot_id, + guild_id.get() as i64, + value, + ¤t.antilink_mode, + ) + .await + .ok() + } else if first.eq_ignore_ascii_case("invite") || first.eq_ignore_ascii_case("all") { + db::set_antilink_settings( + &pool, + bot_id, + guild_id.get() as i64, + current.antilink_enabled, + &first.to_lowercase(), + ) + .await + .ok() + } else { + None + }; + + let Some(updated) = updated else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiLink") + .description(format!( + "Etat: **{}**\nMode: **{}**", + if updated.antilink_enabled { + "ON" + } else { + "OFF" + }, + updated.antilink_mode + )) + .color(0x57F287), + ) + .await; +} + +pub struct AntilinkCommand; +pub static COMMAND_DESCRIPTOR: AntilinkCommand = AntilinkCommand; + +impl crate::commands::command_contract::CommandSpec for AntilinkCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "antilink", + category: "moderation", + params: " | ", + description: "Active ou configure la protection anti liens.", + examples: &["+antilink on", "+antilink invite", "+help antilink"], + default_aliases: &["alink"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/antimassmention.rs b/src/commands/moderation/antimassmention.rs new file mode 100644 index 0000000..f8f3e3b --- /dev/null +++ b/src/commands/moderation/antimassmention.rs @@ -0,0 +1,105 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_on_off, pool}; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_antimassmention(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(first) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiMassMention") + .description("Usage: +antimassmention | +antimassmention ") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let Ok(current) = + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await + else { + return; + }; + + let updated = if let Some(value) = parse_on_off(first) { + db::set_antimassmention_settings( + &pool, + bot_id, + guild_id.get() as i64, + value, + current.antimassmention_limit, + ) + .await + .ok() + } else if let Ok(limit) = first.parse::() { + db::set_antimassmention_settings( + &pool, + bot_id, + guild_id.get() as i64, + current.antimassmention_enabled, + limit.clamp(1, 50), + ) + .await + .ok() + } else { + None + }; + + let Some(updated) = updated else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiMassMention") + .description(format!( + "Etat: **{}**\nSeuil: **{} mention(s)**", + if updated.antimassmention_enabled { + "ON" + } else { + "OFF" + }, + updated.antimassmention_limit + )) + .color(0x57F287), + ) + .await; +} + +pub struct AntimassmentionCommand; +pub static COMMAND_DESCRIPTOR: AntimassmentionCommand = AntimassmentionCommand; + +impl crate::commands::command_contract::CommandSpec for AntimassmentionCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "antimassmention", + category: "moderation", + params: " | ", + description: "Active ou configure la protection anti spam de mentions.", + examples: &[ + "+antimassmention on", + "+antimassmention 6", + "+help antimassmention", + ], + default_aliases: &["amm"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/antiraideautoconfig.rs b/src/commands/moderation/antiraideautoconfig.rs new file mode 100644 index 0000000..4913173 --- /dev/null +++ b/src/commands/moderation/antiraideautoconfig.rs @@ -0,0 +1,137 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; +use crate::db; + +const DEFAULT_STRIKE_RULES: &[(&str, &str, i32)] = &[ + ("spam", "new", 2), + ("spam", "old", 1), + ("link", "new", 2), + ("link", "old", 1), + ("massmention", "new", 3), + ("massmention", "old", 2), + ("badword", "new", 2), + ("badword", "old", 1), +]; + +pub async fn handle_antiraideautoconfig(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + let mut failed = Vec::new(); + + if db::set_antispam_settings(&pool, bot_id, guild_id_raw, true, 6, 5) + .await + .is_err() + { + failed.push("antispam"); + } + + if db::set_antilink_settings(&pool, bot_id, guild_id_raw, true, "invite") + .await + .is_err() + { + failed.push("antilink"); + } + + if db::set_antimassmention_settings(&pool, bot_id, guild_id_raw, true, 5) + .await + .is_err() + { + failed.push("antimassmention"); + } + + if db::set_badwords_enabled(&pool, bot_id, guild_id_raw, true) + .await + .is_err() + { + failed.push("badwords"); + } + + if db::clear_moderation_channel_overrides_by_kind(&pool, bot_id, guild_id_raw, "spam") + .await + .is_err() + { + failed.push("spam overrides"); + } + + if db::clear_moderation_channel_overrides_by_kind(&pool, bot_id, guild_id_raw, "link") + .await + .is_err() + { + failed.push("link overrides"); + } + + for (trigger, profile, strike_count) in DEFAULT_STRIKE_RULES { + if db::upsert_strike_rule(&pool, bot_id, guild_id_raw, trigger, profile, *strike_count) + .await + .is_err() + { + failed.push("strikes"); + break; + } + } + + if db::setup_default_punish_rules(&pool, bot_id, guild_id_raw) + .await + .is_err() + { + failed.push("punish"); + } + + let mut description = String::from( + "Configuration anti raid appliquee.\n\n- Antispam: ON (6/5s)\n- AntiLink: ON (invite)\n- AntiMassMention: ON (5)\n- BadWords: ON\n- Strikes: profils par defaut\n- Punish: regles par defaut", + ); + + if !failed.is_empty() { + description.push_str("\n\nErreurs detectees: "); + description.push_str(&failed.join(", ")); + } + + let color = if failed.is_empty() { + theme_color(ctx).await + } else { + 0xFEE75C + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiRaid AutoConfig") + .description(description) + .color(color), + ) + .await; +} + +pub struct AntiraideautoconfigCommand; +pub static COMMAND_DESCRIPTOR: AntiraideautoconfigCommand = AntiraideautoconfigCommand; + +impl crate::commands::command_contract::CommandSpec for AntiraideautoconfigCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "antiraideautoconfig", + category: "moderation", + params: "aucun", + description: "Configure automatiquement les protections anti raid du serveur.", + examples: &["+antiraideautoconfig", "+help antiraideautoconfig"], + default_aliases: &["arcfg"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/antispam.rs b/src/commands/moderation/antispam.rs new file mode 100644 index 0000000..b093ff5 --- /dev/null +++ b/src/commands/moderation/antispam.rs @@ -0,0 +1,104 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{format_duration, parse_on_off, parse_rate_limit, pool}; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_antispam(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(first) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiSpam") + .description("Usage: +antispam | +antispam /") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + + let Ok(current) = + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await + else { + return; + }; + + let updated = if let Some(value) = parse_on_off(first) { + db::set_antispam_settings( + &pool, + bot_id, + guild_id.get() as i64, + value, + current.antispam_limit, + current.antispam_window_seconds, + ) + .await + .ok() + } else if let Some((limit, window)) = parse_rate_limit(first) { + db::set_antispam_settings( + &pool, + bot_id, + guild_id.get() as i64, + current.antispam_enabled, + limit, + window, + ) + .await + .ok() + } else { + None + }; + + let Some(updated) = updated else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("AntiSpam") + .description(format!( + "Etat: **{}**\nSensibilite: **{}/{}**", + if updated.antispam_enabled { + "ON" + } else { + "OFF" + }, + updated.antispam_limit, + format_duration(updated.antispam_window_seconds as i64) + )) + .color(0x57F287), + ) + .await; +} + +pub struct AntispamCommand; +pub static COMMAND_DESCRIPTOR: AntispamCommand = AntispamCommand; + +impl crate::commands::command_contract::CommandSpec for AntispamCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "antispam", + category: "moderation", + params: " | /", + description: "Active ou configure la protection antispam du serveur.", + examples: &["+antispam on", "+antispam 6/5s", "+help antispam"], + default_aliases: &["aspam"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/badwords.rs b/src/commands/moderation/badwords.rs new file mode 100644 index 0000000..7250d08 --- /dev/null +++ b/src/commands/moderation/badwords.rs @@ -0,0 +1,142 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_on_off, pool}; +use crate::commands::common::{add_list_fields, send_embed}; +use crate::db; + +pub async fn handle_badwords(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + + if args.is_empty() { + let Ok(settings) = + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await + else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BadWords") + .description(format!( + "Etat: **{}**\nUsage: +badwords ", + if settings.badwords_enabled { + "ON" + } else { + "OFF" + } + )) + .color(0x5865F2), + ) + .await; + return; + } + + let action = args[0].to_lowercase(); + + if action == "list" { + let words = db::list_badwords(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or_default(); + let lines = words + .into_iter() + .map(|word| format!("- {}", word)) + .collect::>(); + + let mut embed = CreateEmbed::new().title("BadWords list").color(0x5865F2); + embed = add_list_fields(embed, &lines, "Mots interdits"); + send_embed(ctx, msg, embed).await; + return; + } + + if let Some(value) = parse_on_off(&action) { + let _ = db::set_badwords_enabled(&pool, bot_id, guild_id.get() as i64, value).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BadWords") + .description(format!( + "Protection badwords: **{}**", + if value { "ON" } else { "OFF" } + )) + .color(0x57F287), + ) + .await; + return; + } + + if action == "add" { + let Some(word) = args.get(1) else { + return; + }; + let _ = db::add_badword(&pool, bot_id, guild_id.get() as i64, word).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BadWords") + .description(format!("Mot ajoute: **{}**", word)) + .color(0x57F287), + ) + .await; + return; + } + + if action == "del" || action == "remove" { + let Some(word) = args.get(1) else { + return; + }; + let removed = db::remove_badword(&pool, bot_id, guild_id.get() as i64, word) + .await + .unwrap_or(0); + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BadWords") + .description(format!("Mot supprime: **{}** ({}).", word, removed)) + .color(0x57F287), + ) + .await; + return; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("BadWords") + .description("Usage: +badwords |del |list>") + .color(0xED4245), + ) + .await; +} + +pub struct BadwordsCommand; +pub static COMMAND_DESCRIPTOR: BadwordsCommand = BadwordsCommand; + +impl crate::commands::command_contract::CommandSpec for BadwordsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "badwords", + category: "moderation", + params: "|del |list>", + description: "Active la protection badwords et gere la liste des mots interdits.", + examples: &["+badwords on", "+badwords add insulte", "+badwords list"], + default_aliases: &["bw"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/clear_badwords.rs b/src/commands/moderation/clear_badwords.rs new file mode 100644 index 0000000..b69aac3 --- /dev/null +++ b/src/commands/moderation/clear_badwords.rs @@ -0,0 +1,50 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_clear_badwords(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let cleared = db::clear_badwords(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or(0); + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Clear BadWords") + .description(format!("{} mot(s) interdit(s) supprime(s).", cleared)) + .color(0x57F287), + ) + .await; +} + +pub struct ClearBadwordsCommand; +pub static COMMAND_DESCRIPTOR: ClearBadwordsCommand = ClearBadwordsCommand; + +impl crate::commands::command_contract::CommandSpec for ClearBadwordsCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "clear_badwords", + category: "moderation", + params: "badwords", + description: "Supprime l ensemble des mots interdits enregistres.", + examples: &["+clear badwords", "+help clear badwords"], + default_aliases: &["cbw"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/clear_limit.rs b/src/commands/moderation/clear_limit.rs new file mode 100644 index 0000000..e24ebe5 --- /dev/null +++ b/src/commands/moderation/clear_limit.rs @@ -0,0 +1,74 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_clear_limit(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(raw_value) = args.get(1) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Clear Limit") + .description("Usage: +clear limit ") + .color(0xED4245), + ) + .await; + return; + }; + + let Ok(value) = raw_value.parse::() else { + return; + }; + + let clamped = value.clamp(1, 1_000); + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + if db::set_clear_limit(&pool, bot_id, guild_id.get() as i64, clamped) + .await + .is_err() + { + return; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Clear Limit") + .description(format!( + "Limite de suppression definie a **{}** message(s) par commande clear.", + clamped + )) + .color(0x57F287), + ) + .await; +} + +pub struct ClearLimitCommand; +pub static COMMAND_DESCRIPTOR: ClearLimitCommand = ClearLimitCommand; + +impl crate::commands::command_contract::CommandSpec for ClearLimitCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "clear_limit", + category: "moderation", + params: "limit ", + description: "Definit la limite max de messages supprimables avec +clear.", + examples: &["+clear limit 100", "+help clear limit"], + default_aliases: &["climit"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/clear_messages.rs b/src/commands/moderation/clear_messages.rs index fa141c5..2e82505 100644 --- a/src/commands/moderation/clear_messages.rs +++ b/src/commands/moderation/clear_messages.rs @@ -4,6 +4,7 @@ use serenity::prelude::*; use crate::commands::admin_common::parse_user_id; use crate::commands::common::{send_embed, theme_color}; +use crate::db::{self, DbPoolKey}; pub async fn handle_clear_messages(ctx: &Context, msg: &Message, args: &[&str]) { let Ok(mut amount) = args.first().unwrap_or(&"0").parse::() else { @@ -12,7 +13,28 @@ pub async fn handle_clear_messages(ctx: &Context, msg: &Message, args: &[&str]) if amount == 0 { return; } - amount = amount.clamp(1, 100); + + let max_limit = if let Some(guild_id) = msg.guild_id { + let pool = { + let data = ctx.data.read().await; + data.get::().cloned() + }; + + if let Some(pool) = pool { + let bot_id = ctx.cache.current_user().id.get() as i64; + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64) + .await + .ok() + .map(|settings| settings.clear_limit.max(1) as u64) + .unwrap_or(100) + } else { + 100 + } + } else { + 100 + }; + + amount = amount.clamp(1, max_limit); let filter_user = args.get(1).and_then(|raw| parse_user_id(raw)); diff --git a/src/commands/moderation/link.rs b/src/commands/moderation/link.rs new file mode 100644 index 0000000..2d84933 --- /dev/null +++ b/src/commands/moderation/link.rs @@ -0,0 +1,103 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::{parse_channel_id, send_embed}; +use crate::db; + +pub async fn handle_link_override(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(action) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Link") + .description("Usage: +link [#salon]") + .color(0xED4245), + ) + .await; + return; + }; + + let channel_id = args + .get(1) + .and_then(|raw| parse_channel_id(raw)) + .unwrap_or(msg.channel_id); + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + let channel_id_raw = channel_id.get() as i64; + + let description = if action.eq_ignore_ascii_case("allow") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "link", + "allow", + ) + .await; + format!("AntiLink desactive dans <#{}>.", channel_id.get()) + } else if action.eq_ignore_ascii_case("deny") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "link", + "deny", + ) + .await; + format!("AntiLink force dans <#{}>.", channel_id.get()) + } else if action.eq_ignore_ascii_case("reset") { + let _ = db::remove_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "link", + ) + .await; + format!("Override antilink supprime dans <#{}>.", channel_id.get()) + } else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Link Override") + .description(description) + .color(0x57F287), + ) + .await; +} + +pub struct LinkCommand; +pub static COMMAND_DESCRIPTOR: LinkCommand = LinkCommand; + +impl crate::commands::command_contract::CommandSpec for LinkCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "link", + category: "moderation", + params: " [#salon]", + description: "Definit l override antilink pour un salon (allow, deny, reset).", + examples: &["+link allow #general", "+link deny #regles", "+link reset"], + default_aliases: &["linkch"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/muterole.rs b/src/commands/moderation/muterole.rs new file mode 100644 index 0000000..4f7e5e0 --- /dev/null +++ b/src/commands/moderation/muterole.rs @@ -0,0 +1,141 @@ +use serenity::builder::{CreateEmbed, EditRole}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::send_embed; +use crate::db; + +fn mute_permissions() -> Permissions { + Permissions::SEND_MESSAGES | Permissions::ADD_REACTIONS | Permissions::SPEAK +} + +pub async fn handle_muterole(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let Ok(settings) = + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64).await + else { + return; + }; + + let mut role_id = settings + .mute_role_id + .and_then(|raw| u64::try_from(raw).ok()) + .map(RoleId::new); + + let Ok(partial_guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + + if role_id.is_none() { + role_id = partial_guild + .roles + .values() + .find(|role| role.name.eq_ignore_ascii_case("Muted")) + .map(|role| role.id); + } + + if role_id.is_none() { + let created = guild_id + .create_role( + &ctx.http, + EditRole::new() + .name("Muted") + .permissions(Permissions::empty()), + ) + .await + .ok(); + role_id = created.map(|role| role.id); + } + + let Some(role_id) = role_id else { + return; + }; + + let mut failed_channels = Vec::new(); + if let Ok(channels) = guild_id.channels(&ctx.http).await { + for channel in channels.values() { + let result = channel + .create_permission( + &ctx.http, + PermissionOverwrite { + allow: Permissions::empty(), + deny: mute_permissions(), + kind: PermissionOverwriteType::Role(role_id), + }, + ) + .await; + + if result.is_err() { + failed_channels.push(channel.id.get()); + } + } + } + + let _ = db::set_mute_role( + &pool, + bot_id, + guild_id.get() as i64, + Some(role_id.get() as i64), + ) + .await; + + let mut description = format!( + "Role muet configure: <@&{}>.\nPermissions appliquees sur les salons du serveur.", + role_id.get() + ); + + if !failed_channels.is_empty() { + let list = failed_channels + .iter() + .take(10) + .map(|id| format!("<#{}>", id)) + .collect::>() + .join(", "); + description.push_str(&format!( + "\nErreurs permissions: {} salon(s). {}", + failed_channels.len(), + list + )); + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("MuteRole") + .description(description) + .color(if failed_channels.is_empty() { + 0x57F287 + } else { + 0xFEE75C + }), + ) + .await; +} + +pub struct MuteRoleCommand; +pub static COMMAND_DESCRIPTOR: MuteRoleCommand = MuteRoleCommand; + +impl crate::commands::command_contract::CommandSpec for MuteRoleCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "muterole", + category: "moderation", + params: "aucun", + description: "Cree ou met a jour le role muet et tente de corriger les permissions des salons.", + examples: &["+muterole", "+help muterole"], + default_aliases: &["mr"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/public.rs b/src/commands/moderation/public.rs new file mode 100644 index 0000000..4e1af0d --- /dev/null +++ b/src/commands/moderation/public.rs @@ -0,0 +1,126 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_on_off, pool}; +use crate::commands::common::{parse_channel_id, send_embed}; +use crate::db; + +pub async fn handle_public(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(first) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Public") + .description("Usage: +public | +public [#salon]") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + if let Some(enabled) = parse_on_off(first) { + let _ = db::set_public_commands_enabled(&pool, bot_id, guild_id_raw, enabled).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Public") + .description(format!( + "Commandes publiques sur le serveur: **{}**", + if enabled { "ON" } else { "OFF" } + )) + .color(0x57F287), + ) + .await; + return; + } + + let channel_id = args + .get(1) + .and_then(|raw| parse_channel_id(raw)) + .unwrap_or(msg.channel_id); + + let description = if first.eq_ignore_ascii_case("allow") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id.get() as i64, + "public", + "allow", + ) + .await; + format!("Commandes publiques forcees dans <#{}>.", channel_id.get()) + } else if first.eq_ignore_ascii_case("deny") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id.get() as i64, + "public", + "deny", + ) + .await; + format!( + "Commandes publiques desactivees dans <#{}>.", + channel_id.get() + ) + } else if first.eq_ignore_ascii_case("reset") { + let _ = db::remove_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id.get() as i64, + "public", + ) + .await; + format!("Override public supprime dans <#{}>.", channel_id.get()) + } else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Public") + .description(description) + .color(0x57F287), + ) + .await; +} + +pub struct PublicCommand; +pub static COMMAND_DESCRIPTOR: PublicCommand = PublicCommand; + +impl crate::commands::command_contract::CommandSpec for PublicCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "public", + category: "moderation", + params: " | [#salon]", + description: "Active/desactive les commandes publiques globalement ou par salon.", + examples: &[ + "+public on", + "+public deny #annonces", + "+public reset #annonces", + ], + default_aliases: &["pubc"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/punish.rs b/src/commands/moderation/punish.rs new file mode 100644 index 0000000..b08cd21 --- /dev/null +++ b/src/commands/moderation/punish.rs @@ -0,0 +1,176 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{ + format_duration, parse_duration_to_seconds, parse_sanction, pool, +}; +use crate::commands::common::send_embed; +use crate::db; + +fn describe_rule(index: usize, rule: &db::PunishRule) -> String { + let sanction = if let Some(duration) = rule.sanction_seconds { + format!("{} {}", rule.sanction, format_duration(duration)) + } else { + rule.sanction.clone() + }; + + format!( + "{}. {} strikes / {} -> {}", + index, + rule.threshold, + format_duration(rule.window_seconds), + sanction + ) +} + +pub async fn handle_punish(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + if args.is_empty() { + let rules = db::list_punish_rules(&pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + + let description = if rules.is_empty() { + "Aucune regle.".to_string() + } else { + rules + .iter() + .enumerate() + .map(|(idx, rule)| describe_rule(idx + 1, rule)) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Punish") + .description(description) + .color(0x5865F2), + ) + .await; + return; + } + + if args[0].eq_ignore_ascii_case("setup") { + let _ = db::setup_default_punish_rules(&pool, bot_id, guild_id_raw).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Punish") + .description("Regles par defaut restaurees.") + .color(0x57F287), + ) + .await; + return; + } + + if args[0].eq_ignore_ascii_case("add") { + if args.len() < 4 { + return; + } + + let Ok(threshold) = args[1].parse::() else { + return; + }; + let Some(window_seconds) = parse_duration_to_seconds(args[2]) else { + return; + }; + let Some(sanction) = parse_sanction(args[3]) else { + return; + }; + let sanction_seconds = args.get(4).and_then(|raw| parse_duration_to_seconds(raw)); + + let _ = db::upsert_punish_rule( + &pool, + bot_id, + guild_id_raw, + threshold.clamp(1, 200), + window_seconds, + sanction, + sanction_seconds, + ) + .await; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Punish") + .description("Regle ajoutee ou mise a jour.") + .color(0x57F287), + ) + .await; + return; + } + + if args[0].eq_ignore_ascii_case("del") { + let Some(raw_index) = args.get(1) else { + return; + }; + let Ok(index) = raw_index.parse::() else { + return; + }; + + let rules = db::list_punish_rules(&pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + if index == 0 || index > rules.len() { + return; + } + + let rule = &rules[index - 1]; + let _ = db::delete_punish_rule_by_id(&pool, bot_id, guild_id_raw, rule.id).await; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Punish") + .description(format!("Regle {} supprimee.", index)) + .color(0x57F287), + ) + .await; + return; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Punish") + .description("Usage: +punish | +punish add [duree] | +punish del | +punish setup") + .color(0xED4245), + ) + .await; +} + +pub struct PunishCommand; +pub static COMMAND_DESCRIPTOR: PunishCommand = PunishCommand; + +impl crate::commands::command_contract::CommandSpec for PunishCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "punish", + category: "moderation", + params: "[add [duree] | del | setup]", + description: "Affiche et gere les sanctions automatiques appliquees selon les strikes.", + examples: &["+punish", "+punish add 8 1h mute 30m", "+punish setup"], + default_aliases: &["pn"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/resetantiraide.rs b/src/commands/moderation/resetantiraide.rs new file mode 100644 index 0000000..b04f129 --- /dev/null +++ b/src/commands/moderation/resetantiraide.rs @@ -0,0 +1,144 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{send_embed, theme_color}; +use crate::db; + +const DEFAULT_STRIKE_RULES: &[(&str, &str, i32)] = &[ + ("spam", "new", 2), + ("spam", "old", 1), + ("link", "new", 2), + ("link", "old", 1), + ("massmention", "new", 3), + ("massmention", "old", 2), + ("badword", "new", 2), + ("badword", "old", 1), +]; + +pub async fn handle_resetantiraide(ctx: &Context, msg: &Message, _args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + let mut failed = Vec::new(); + + if db::set_antispam_settings(&pool, bot_id, guild_id_raw, false, 6, 5) + .await + .is_err() + { + failed.push("antispam"); + } + + if db::set_antilink_settings(&pool, bot_id, guild_id_raw, false, "invite") + .await + .is_err() + { + failed.push("antilink"); + } + + if db::set_antimassmention_settings(&pool, bot_id, guild_id_raw, false, 5) + .await + .is_err() + { + failed.push("antimassmention"); + } + + if db::set_badwords_enabled(&pool, bot_id, guild_id_raw, false) + .await + .is_err() + { + failed.push("badwords"); + } + + if db::clear_badwords(&pool, bot_id, guild_id_raw) + .await + .is_err() + { + failed.push("clear badwords"); + } + + if db::clear_moderation_channel_overrides_by_kind(&pool, bot_id, guild_id_raw, "spam") + .await + .is_err() + { + failed.push("spam overrides"); + } + + if db::clear_moderation_channel_overrides_by_kind(&pool, bot_id, guild_id_raw, "link") + .await + .is_err() + { + failed.push("link overrides"); + } + + for (trigger, profile, strike_count) in DEFAULT_STRIKE_RULES { + if db::upsert_strike_rule(&pool, bot_id, guild_id_raw, trigger, profile, *strike_count) + .await + .is_err() + { + failed.push("strikes"); + break; + } + } + + if db::setup_default_punish_rules(&pool, bot_id, guild_id_raw) + .await + .is_err() + { + failed.push("punish"); + } + + let mut description = String::from( + "Les protections anti raid ont ete remises a leur etat par defaut.\n\n- Antispam: OFF (6/5s)\n- AntiLink: OFF (invite)\n- AntiMassMention: OFF (5)\n- BadWords: OFF et liste vide\n- Overrides spam/link: supprimes\n- Strikes: profils par defaut\n- Punish: regles par defaut", + ); + + if !failed.is_empty() { + description.push_str("\n\nErreurs detectees: "); + description.push_str(&failed.join(", ")); + } + + let color = if failed.is_empty() { + theme_color(ctx).await + } else { + 0xFEE75C + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Reset AntiRaid") + .description(description) + .color(color), + ) + .await; +} + +pub struct ResetantiraideCommand; +pub static COMMAND_DESCRIPTOR: ResetantiraideCommand = ResetantiraideCommand; + +impl crate::commands::command_contract::CommandSpec for ResetantiraideCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "resetantiraide", + category: "moderation", + params: "aucun", + description: "Arrete et reinitialise les protections anti raid avec les valeurs par defaut.", + examples: &["+resetantiraide", "+help resetantiraide"], + default_aliases: &["rra"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/set_muterole.rs b/src/commands/moderation/set_muterole.rs new file mode 100644 index 0000000..217eaba --- /dev/null +++ b/src/commands/moderation/set_muterole.rs @@ -0,0 +1,79 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::{parse_role, send_embed}; +use crate::db; + +pub async fn handle_set_muterole(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(raw_role) = args.get(1) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Set MuteRole") + .description("Usage: +set muterole <@role/ID/nom>") + .color(0xED4245), + ) + .await; + return; + }; + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + + let Some(role) = parse_role(&guild, raw_role) else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + if db::set_mute_role( + &pool, + bot_id, + guild_id.get() as i64, + Some(role.id.get() as i64), + ) + .await + .is_err() + { + return; + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("MuteRole") + .description(format!("Role muet defini sur <@&{}>.", role.id.get())) + .color(0x57F287), + ) + .await; +} + +pub struct SetMuteRoleCommand; +pub static COMMAND_DESCRIPTOR: SetMuteRoleCommand = SetMuteRoleCommand; + +impl crate::commands::command_contract::CommandSpec for SetMuteRoleCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "set_muterole", + category: "moderation", + params: "muterole <@role/ID/nom>", + description: "Definit le role utilise pour le mute lorsque le mode timeout est desactive.", + examples: &["+set muterole @Muted", "+help set muterole"], + default_aliases: &["smr"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/spam.rs b/src/commands/moderation/spam.rs new file mode 100644 index 0000000..0d155a7 --- /dev/null +++ b/src/commands/moderation/spam.rs @@ -0,0 +1,103 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::{parse_channel_id, send_embed}; +use crate::db; + +pub async fn handle_spam_override(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(action) = args.first() else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Spam") + .description("Usage: +spam [#salon]") + .color(0xED4245), + ) + .await; + return; + }; + + let channel_id = args + .get(1) + .and_then(|raw| parse_channel_id(raw)) + .unwrap_or(msg.channel_id); + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + let channel_id_raw = channel_id.get() as i64; + + let description = if action.eq_ignore_ascii_case("allow") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "spam", + "allow", + ) + .await; + format!("Antispam desactive dans <#{}>.", channel_id.get()) + } else if action.eq_ignore_ascii_case("deny") { + let _ = db::set_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "spam", + "deny", + ) + .await; + format!("Antispam force dans <#{}>.", channel_id.get()) + } else if action.eq_ignore_ascii_case("reset") { + let _ = db::remove_moderation_channel_override( + &pool, + bot_id, + guild_id_raw, + channel_id_raw, + "spam", + ) + .await; + format!("Override antispam supprime dans <#{}>.", channel_id.get()) + } else { + return; + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Spam Override") + .description(description) + .color(0x57F287), + ) + .await; +} + +pub struct SpamCommand; +pub static COMMAND_DESCRIPTOR: SpamCommand = SpamCommand; + +impl crate::commands::command_contract::CommandSpec for SpamCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "spam", + category: "moderation", + params: " [#salon]", + description: "Definit l override antispam pour un salon (allow, deny, reset).", + examples: &["+spam allow #general", "+spam deny #flood", "+spam reset"], + default_aliases: &["spamch"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/strikes.rs b/src/commands/moderation/strikes.rs new file mode 100644 index 0000000..f605c3c --- /dev/null +++ b/src/commands/moderation/strikes.rs @@ -0,0 +1,119 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_profile, parse_trigger, pool}; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_strikes(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + if args.is_empty() { + let rules = db::list_strike_rules(&pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + + let mut lines = Vec::new(); + for trigger in ["spam", "link", "massmention", "badword"] { + let new_count = rules + .iter() + .find(|r| r.trigger == trigger && r.profile == "new") + .map(|r| r.strike_count) + .unwrap_or(0); + let old_count = rules + .iter() + .find(|r| r.trigger == trigger && r.profile == "old") + .map(|r| r.strike_count) + .unwrap_or(0); + + lines.push(format!( + "`{}` -> nouveau: `{}` | ancien: `{}`", + trigger, new_count, old_count + )); + } + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Strikes") + .description(lines.join("\n")) + .color(0x5865F2), + ) + .await; + return; + } + + if args.len() < 2 { + return; + } + + let Some(trigger) = parse_trigger(args[0]) else { + return; + }; + let Ok(count) = args[1].parse::() else { + return; + }; + let count = count.clamp(0, 20); + + if let Some(profile) = parse_profile(args.get(2).copied()) { + let _ = db::upsert_strike_rule(&pool, bot_id, guild_id_raw, trigger, profile, count).await; + } else { + let _ = db::upsert_strike_rule(&pool, bot_id, guild_id_raw, trigger, "new", count).await; + let _ = db::upsert_strike_rule(&pool, bot_id, guild_id_raw, trigger, "old", count).await; + } + + let rules = db::list_strike_rules(&pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + let new_count = rules + .iter() + .find(|r| r.trigger == trigger && r.profile == "new") + .map(|r| r.strike_count) + .unwrap_or(0); + let old_count = rules + .iter() + .find(|r| r.trigger == trigger && r.profile == "old") + .map(|r| r.strike_count) + .unwrap_or(0); + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Strikes") + .description(format!( + "Regle mise a jour pour `{}`\nNouveau: `{}`\nAncien: `{}`", + trigger, new_count, old_count + )) + .color(0x57F287), + ) + .await; +} + +pub struct StrikesCommand; +pub static COMMAND_DESCRIPTOR: StrikesCommand = StrikesCommand; + +impl crate::commands::command_contract::CommandSpec for StrikesCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "strikes", + category: "moderation", + params: "[ [ancien/nouveau]]", + description: "Affiche ou modifie les strikes attribues pour chaque trigger automod.", + examples: &["+strikes", "+strikes spam 2", "+strikes link 1 ancien"], + default_aliases: &["stk"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation/timeout.rs b/src/commands/moderation/timeout.rs new file mode 100644 index 0000000..33536a4 --- /dev/null +++ b/src/commands/moderation/timeout.rs @@ -0,0 +1,74 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::{parse_on_off, pool}; +use crate::commands::common::send_embed; +use crate::db; + +pub async fn handle_timeout_toggle(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(value) = args.first().and_then(|raw| parse_on_off(raw)) else { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Timeout") + .description("Usage: +timeout ") + .color(0xED4245), + ) + .await; + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let Ok(settings) = + db::set_use_timeout_for_mute(&pool, bot_id, guild_id.get() as i64, value).await + else { + return; + }; + + let mode = if settings.use_timeout { + "Timeout Discord" + } else { + "Role mute" + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Timeout") + .description(format!( + "Mode mute mis a jour: **{}**.\nNote: les timeouts Discord sont limites a 28 jours.", + mode + )) + .color(0x57F287), + ) + .await; +} + +pub struct TimeoutCommand; +pub static COMMAND_DESCRIPTOR: TimeoutCommand = TimeoutCommand; + +impl crate::commands::command_contract::CommandSpec for TimeoutCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "timeout", + category: "moderation", + params: "", + description: "Active ou desactive l utilisation du timeout Discord pour les mutes.", + examples: &["+timeout on", "+timeout off", "+help timeout"], + default_aliases: &["to"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/moderation_sanction_helpers.rs b/src/commands/moderation_sanction_helpers.rs index 72140c6..309cf03 100644 --- a/src/commands/moderation_sanction_helpers.rs +++ b/src/commands/moderation_sanction_helpers.rs @@ -6,7 +6,7 @@ use serenity::model::prelude::*; use serenity::prelude::*; use crate::commands::admin_common::parse_user_id; -use crate::db::DbPoolKey; +use crate::db::{self, DbPoolKey}; pub fn duration_from_input(input: &str) -> Option { let raw = input.trim().to_lowercase(); @@ -95,20 +95,56 @@ pub async fn handle_timeout( users: &[UserId], expires: Option>, ) -> usize { + let settings = if let Some(pool) = pool(ctx).await { + let bot_id = ctx.cache.current_user().id.get() as i64; + db::get_or_create_moderation_settings(&pool, bot_id, guild_id.get() as i64) + .await + .ok() + } else { + None + }; + + let mute_role_id = settings + .as_ref() + .and_then(|s| s.mute_role_id) + .and_then(|raw| u64::try_from(raw).ok()) + .map(RoleId::new); + + let use_timeout = settings + .as_ref() + .map(|s| s.use_timeout || s.mute_role_id.is_none()) + .unwrap_or(true); + let mut done = 0usize; for user_id in users { if let Ok(mut member) = guild_id.member(&ctx.http, *user_id).await { - let mut builder = EditMember::new(); - if let Some(ts) = expires { - if let Ok(discord_ts) = Timestamp::from_unix_timestamp(ts.timestamp()) { - builder = builder.disable_communication_until_datetime(discord_ts); + if use_timeout { + let mut builder = EditMember::new(); + if let Some(ts) = expires { + if let Ok(discord_ts) = Timestamp::from_unix_timestamp(ts.timestamp()) { + builder = builder.disable_communication_until_datetime(discord_ts); + } + } else { + builder = builder.enable_communication(); + } + + if member.edit(&ctx.http, builder).await.is_ok() { + done += 1; } } else { - builder = builder.enable_communication(); - } + let Some(role_id) = mute_role_id else { + continue; + }; - if member.edit(&ctx.http, builder).await.is_ok() { - done += 1; + let result = if expires.is_some() { + member.add_role(&ctx.http, role_id).await + } else { + member.remove_role(&ctx.http, role_id).await + }; + + if result.is_ok() { + done += 1; + } } } } diff --git a/src/commands/moderation_tools.rs b/src/commands/moderation_tools.rs index 114766c..5924bf6 100644 --- a/src/commands/moderation_tools.rs +++ b/src/commands/moderation_tools.rs @@ -2,10 +2,10 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use chrono::Utc; -use serenity::builder::EditMember; use serenity::model::prelude::*; use serenity::prelude::*; +use crate::commands::moderation_sanction_helpers::{channel_mute_users, handle_timeout}; use crate::db::DbPoolKey; static MODERATION_TICK: OnceLock> = OnceLock::new(); @@ -15,66 +15,6 @@ async fn pool(ctx: &Context) -> Option { data.get::().cloned() } -async fn handle_timeout( - ctx: &Context, - guild_id: GuildId, - users: &[UserId], - expires: Option>, -) -> usize { - let mut done = 0usize; - for user_id in users { - if let Ok(mut member) = guild_id.member(&ctx.http, *user_id).await { - let mut builder = EditMember::new(); - if let Some(ts) = expires { - if let Ok(discord_ts) = Timestamp::from_unix_timestamp(ts.timestamp()) { - builder = builder.disable_communication_until_datetime(discord_ts); - } - } else { - builder = builder.enable_communication(); - } - - if member.edit(&ctx.http, builder).await.is_ok() { - done += 1; - } - } - } - done -} - -async fn channel_mute_users( - ctx: &Context, - channel_id: ChannelId, - users: &[UserId], - mute: bool, -) -> usize { - let mut done = 0usize; - for user_id in users { - let result = if mute { - channel_id - .create_permission( - &ctx.http, - PermissionOverwrite { - allow: Permissions::empty(), - deny: Permissions::SEND_MESSAGES - | Permissions::ADD_REACTIONS - | Permissions::SPEAK, - kind: PermissionOverwriteType::Member(*user_id), - }, - ) - .await - } else { - channel_id - .delete_permission(&ctx.http, PermissionOverwriteType::Member(*user_id)) - .await - }; - - if result.is_ok() { - done += 1; - } - } - done -} - pub async fn maybe_run_maintenance(ctx: &Context, guild_id: Option) { let Some(guild_id) = guild_id else { return; diff --git a/src/commands/outils/piconly.rs b/src/commands/outils/piconly.rs new file mode 100644 index 0000000..c4e4159 --- /dev/null +++ b/src/commands/outils/piconly.rs @@ -0,0 +1,209 @@ +use chrono::Utc; +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::{parse_channel_id, send_embed}; +use crate::db; + +fn is_image_filename(filename: &str) -> bool { + let extension = filename + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + + matches!( + extension.as_str(), + "jpg" | "jpeg" | "png" | "gif" | "webp" | "bmp" | "heic" | "heif" + ) +} + +fn has_only_photo_attachments(msg: &Message) -> bool { + !msg.attachments.is_empty() + && msg + .attachments + .iter() + .all(|attachment| is_image_filename(&attachment.filename)) +} + +fn is_piconly_command_message(content: &str, prefix: &str) -> bool { + if !content.starts_with(prefix) { + return false; + } + + let without_prefix = content.trim_start_matches(prefix).trim(); + without_prefix + .split_whitespace() + .next() + .map(|command| command.eq_ignore_ascii_case("piconly")) + .unwrap_or(false) +} + +pub async fn enforce_piconly_message( + ctx: &Context, + msg: &Message, + content: &str, + prefix: &str, +) -> bool { + let Some(guild_id) = msg.guild_id else { + return false; + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return false; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let is_selfie_channel = db::is_piconly_channel( + &pool, + bot_id, + guild_id.get() as i64, + msg.channel_id.get() as i64, + ) + .await + .unwrap_or(false); + + if !is_selfie_channel || is_piconly_command_message(content, prefix) { + return false; + } + + if has_only_photo_attachments(msg) { + return false; + } + + let _ = msg.delete(&ctx.http).await; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("Salon selfie") + .description("Seules les photos sont autorisees dans ce salon.") + .color(0xED4245) + .timestamp(Utc::now()), + ) + .await; + + true +} + +pub async fn handle_piconly(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = ({ + let data = ctx.data.read().await; + data.get::().cloned() + }) else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_i64 = guild_id.get() as i64; + + if args.is_empty() { + let channels = db::get_piconly_channels(&pool, bot_id, guild_id_i64) + .await + .unwrap_or_default(); + + let description = if channels.is_empty() { + "Aucun salon selfie configure.".to_string() + } else { + channels + .into_iter() + .map(|channel| format!("<#{}>", channel.channel_id)) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("PicOnly") + .description(description) + .timestamp(Utc::now()), + ) + .await; + return; + } + + let adding = args[0].eq_ignore_ascii_case("add"); + let deleting = args[0].eq_ignore_ascii_case("del") + || args[0].eq_ignore_ascii_case("remove") + || args[0].eq_ignore_ascii_case("delete"); + + if !adding && !deleting { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("PicOnly") + .description("Utilisation: +piconly [#salon]") + .color(0xED4245), + ) + .await; + return; + } + + let channel_id = args + .get(1) + .and_then(|raw| parse_channel_id(raw)) + .unwrap_or(msg.channel_id); + + let result = if adding { + db::add_piconly_channel(&pool, bot_id, guild_id_i64, channel_id.get() as i64).await + } else { + db::remove_piconly_channel(&pool, bot_id, guild_id_i64, channel_id.get() as i64).await + }; + + if result.is_err() { + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("PicOnly") + .description("Impossible de mettre a jour le salon selfie.") + .color(0xED4245), + ) + .await; + return; + } + + let embed = if adding { + CreateEmbed::new() + .title("Salon selfie ajoute") + .description(format!("Salon: <#{}>", channel_id.get())) + .timestamp(Utc::now()) + } else { + CreateEmbed::new() + .title("Salon selfie retire") + .description(format!("Salon: <#{}>", channel_id.get())) + .timestamp(Utc::now()) + }; + + send_embed(ctx, msg, embed).await; +} + +pub struct PiconlyCommand; +pub static COMMAND_DESCRIPTOR: PiconlyCommand = PiconlyCommand; + +impl crate::commands::command_contract::CommandSpec for PiconlyCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "piconly", + category: "outils", + params: " [salon]", + description: "Definit ou supprime un salon selfie, ou les membres ne peuvent envoyer que des photos.", + examples: &["+piconly", "+piconly add #selfie", "+piconly del #selfie"], + default_aliases: &["selfieonly"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/permissions/help.rs b/src/commands/permissions/help.rs index f9c6bf6..e275b4b 100644 --- a/src/commands/permissions/help.rs +++ b/src/commands/permissions/help.rs @@ -448,7 +448,10 @@ fn help_lookup_to_key(input: &str) -> Option<&'static str> { matched.or_else(|| help_metadata_lookup_key(input)) } -fn resolve_help_command_key(input: &str, alias_map: &BTreeMap>) -> Option { +fn resolve_help_command_key( + input: &str, + alias_map: &BTreeMap>, +) -> Option { if let Some(key) = help_lookup_to_key(input) { return Some(key.to_string()); } @@ -628,7 +631,10 @@ fn build_help_view_pages( let mut intro_lines = Vec::with_capacity(4 + HELP_PAGES.len()); intro_lines.push("Shadow Bot est un bot de gestion de serveur.".to_string()); intro_lines.push(String::new()); - intro_lines.push(format!("**Nombre total de commandes :** {}", total_commands)); + intro_lines.push(format!( + "**Nombre total de commandes :** {}", + total_commands + )); intro_lines.push("**Nombre de commandes par catรฉgorie :**".to_string()); for (index, page) in HELP_PAGES.iter().enumerate() { intro_lines.push(format!("โ€ข {} : {}", page.title, counts[index])); @@ -767,9 +773,8 @@ fn help_components( HelpLayout::Select | HelpLayout::Hybrid => { let mut options = Vec::with_capacity(HELP_PAGES.len() + 1); options.push( - CreateSelectMenuOption::new("Prรฉsentation", "0").description( - "Shadow Bot, total des commandes et rรฉpartition par catรฉgorie.", - ), + CreateSelectMenuOption::new("Prรฉsentation", "0") + .description("Shadow Bot, total des commandes et rรฉpartition par catรฉgorie."), ); for (index, page) in HELP_PAGES.iter().enumerate() { @@ -849,7 +854,10 @@ async fn build_command_help_embed( .join("\n"); let mut embed = CreateEmbed::new() - .title(format!("Aide commande ยท +{}", doc.command.replace('_', " "))) + .title(format!( + "Aide commande ยท +{}", + doc.command.replace('_', " ") + )) .description(doc.description) .field( "Commande", diff --git a/src/commands/roles/ancien.rs b/src/commands/roles/ancien.rs new file mode 100644 index 0000000..1165172 --- /dev/null +++ b/src/commands/roles/ancien.rs @@ -0,0 +1,507 @@ +use chrono::Utc; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateEmbed, CreateInputText, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateMessage, CreateModal, +}; +use serenity::model::application::{ + ActionRowComponent, ButtonStyle, ComponentInteraction, InputTextStyle, ModalInteraction, +}; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::common::theme_color; +use crate::db; + +const ANCIEN_MENU: &str = "ancien:settings"; +const ANCIEN_ROLE_INPUT_ID: &str = "role_id"; +const ANCIEN_DELAY_INPUT_ID: &str = "delay"; + +fn parse_owner_id(custom_id: &str) -> Option<(String, u64)> { + let mut parts = custom_id.rsplitn(2, ':'); + let owner = parts.next()?.parse::().ok()?; + let action = parts.next()?.to_string(); + Some((action, owner)) +} + +fn modal_value(modal: &ModalInteraction, wanted_id: &str) -> Option { + for row in &modal.data.components { + for component in &row.components { + if let ActionRowComponent::InputText(input) = component { + if input.custom_id == wanted_id { + return input.value.clone(); + } + } + } + } + None +} + +fn parse_role_id_input(raw: &str) -> Option { + let cleaned = raw.trim().trim_start_matches("<@&").trim_end_matches('>'); + cleaned.parse::().ok().map(RoleId::new) +} + +fn parse_delay_seconds(input: &str) -> Option { + let raw = input.trim().to_lowercase(); + if raw.is_empty() { + return None; + } + + let mut number = String::new(); + let mut suffix = String::new(); + + for ch in raw.chars() { + if ch.is_ascii_digit() { + if !suffix.is_empty() { + return None; + } + number.push(ch); + } else if !ch.is_whitespace() { + suffix.push(ch); + } + } + + let value = number.parse::().ok()?; + if value <= 0 { + return None; + } + + let unit = if suffix.is_empty() { "j" } else { &suffix }; + + let seconds = match unit { + "s" | "sec" | "secs" | "seconde" | "secondes" => value, + "m" | "min" | "mins" | "minute" | "minutes" => value.checked_mul(60)?, + "h" | "heure" | "heures" => value.checked_mul(3_600)?, + "j" | "d" | "jour" | "jours" => value.checked_mul(86_400)?, + "w" | "sem" | "semaine" | "semaines" => value.checked_mul(604_800)?, + _ => return None, + }; + + Some(seconds.max(1)) +} + +fn format_delay(seconds: i64) -> String { + let mut remaining = seconds.max(1); + let days = remaining / 86_400; + remaining %= 86_400; + let hours = remaining / 3_600; + remaining %= 3_600; + let minutes = remaining / 60; + + let mut parts = Vec::new(); + if days > 0 { + parts.push(format!("{}j", days)); + } + if hours > 0 { + parts.push(format!("{}h", hours)); + } + if minutes > 0 { + parts.push(format!("{}m", minutes)); + } + if parts.is_empty() { + parts.push(format!("{}s", seconds.max(1))); + } + + parts.join(" ") +} + +fn ancien_embed(settings: &db::OldMemberSettings) -> CreateEmbed { + let role_label = settings + .role_id + .and_then(|id| u64::try_from(id).ok()) + .map(|id| format!("<@&{}>", id)) + .unwrap_or_else(|| "Non configure".to_string()); + + CreateEmbed::new() + .title("Ancien") + .description("Definit au bout de combien de temps un membre devient ancien sur le serveur.") + .field( + "Statut", + if settings.enabled { "Actif" } else { "Inactif" }, + true, + ) + .field("Role ancien", role_label, true) + .field("Delai", format_delay(settings.delay_seconds), true) + .field( + "Configuration", + "Utilise le bouton Configurer pour definir l'ID du role et le delai.", + false, + ) + .timestamp(Utc::now()) +} + +fn ancien_components(owner_id: UserId, settings: &db::OldMemberSettings) -> Vec { + let toggle_label = if settings.enabled { + "Desactiver" + } else { + "Activer" + }; + + let toggle_style = if settings.enabled { + ButtonStyle::Danger + } else { + ButtonStyle::Success + }; + + vec![CreateActionRow::Buttons(vec![ + CreateButton::new(format!("{}:toggle:{}", ANCIEN_MENU, owner_id.get())) + .label(toggle_label) + .style(toggle_style), + CreateButton::new(format!("{}:configure:{}", ANCIEN_MENU, owner_id.get())) + .label("Configurer") + .style(ButtonStyle::Primary), + CreateButton::new(format!("{}:refresh:{}", ANCIEN_MENU, owner_id.get())) + .label("Rafraichir") + .style(ButtonStyle::Secondary), + ])] +} + +async fn pool(ctx: &Context) -> Option { + let data = ctx.data.read().await; + data.get::().cloned() +} + +async fn show_menu(ctx: &Context, msg: &Message) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let settings = db::get_or_create_old_member_settings(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or(db::OldMemberSettings { + bot_id, + guild_id: guild_id.get() as i64, + role_id: None, + delay_seconds: 2_592_000, + enabled: false, + updated_at: Utc::now(), + }); + + let _ = msg + .channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .embed(ancien_embed(&settings).color(theme_color(ctx).await)) + .components(ancien_components(msg.author.id, &settings)), + ) + .await; +} + +async fn respond_ephemeral_component( + ctx: &Context, + component: &ComponentInteraction, + content: &str, +) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +async fn respond_ephemeral_modal(ctx: &Context, modal: &ModalInteraction, content: &str) { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +pub async fn handle_ancien(ctx: &Context, msg: &Message, _args: &[&str]) { + show_menu(ctx, msg).await; +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + if !component.data.custom_id.starts_with(ANCIEN_MENU) { + return false; + } + + let Some((action, owner_id)) = parse_owner_id(&component.data.custom_id) else { + return false; + }; + + if component.user.id.get() != owner_id { + respond_ephemeral_component( + ctx, + component, + "Seul l'auteur du menu peut utiliser ces boutons.", + ) + .await; + return true; + } + + let Some(guild_id) = component.guild_id else { + return true; + }; + + let Some(pool) = pool(ctx).await else { + return true; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let settings = db::get_or_create_old_member_settings(&pool, bot_id, guild_id.get() as i64) + .await + .ok(); + + let Some(settings) = settings else { + return true; + }; + + if action.ends_with(":configure") { + let modal = CreateModal::new(component.data.custom_id.clone(), "Configurer Ancien") + .components(vec![ + CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Short, + "ID du role ancien (ou mention)", + ANCIEN_ROLE_INPUT_ID, + ) + .required(true), + ), + CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Short, + "Delai (ex: 30j, 72h, 90m)", + ANCIEN_DELAY_INPUT_ID, + ) + .required(true), + ), + ]); + + let _ = component + .create_response(&ctx.http, CreateInteractionResponse::Modal(modal)) + .await; + return true; + } + + if action.ends_with(":toggle") { + if !settings.enabled && settings.role_id.is_none() { + respond_ephemeral_component( + ctx, + component, + "Configure d'abord le role et le delai avant d'activer.", + ) + .await; + return true; + } + + let updated = db::update_old_member_settings( + &pool, + bot_id, + guild_id.get() as i64, + settings.role_id, + settings.delay_seconds, + !settings.enabled, + ) + .await + .ok(); + + if let Some(updated) = updated { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(ancien_embed(&updated).color(theme_color(ctx).await)) + .components(ancien_components(component.user.id, &updated)), + ), + ) + .await; + } + + return true; + } + + if action.ends_with(":refresh") { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(ancien_embed(&settings).color(theme_color(ctx).await)) + .components(ancien_components(component.user.id, &settings)), + ), + ) + .await; + return true; + } + + false +} + +pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { + if !modal.data.custom_id.starts_with(ANCIEN_MENU) { + return false; + } + + let Some((action, owner_id)) = parse_owner_id(&modal.data.custom_id) else { + return false; + }; + + if modal.user.id.get() != owner_id { + respond_ephemeral_modal( + ctx, + modal, + "Seul l'auteur du menu peut soumettre ce formulaire.", + ) + .await; + return true; + } + + if !action.contains(":configure") { + return false; + } + + let Some(guild_id) = modal.guild_id else { + return true; + }; + + let Some(pool) = pool(ctx).await else { + return true; + }; + + let role_raw = modal_value(modal, ANCIEN_ROLE_INPUT_ID).unwrap_or_default(); + let Some(role_id) = parse_role_id_input(&role_raw) else { + respond_ephemeral_modal(ctx, modal, "Role invalide. Fournis un ID ou une mention.").await; + return true; + }; + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + respond_ephemeral_modal(ctx, modal, "Impossible de verifier le role sur ce serveur.").await; + return true; + }; + + if !guild.roles.contains_key(&role_id) { + respond_ephemeral_modal(ctx, modal, "Le role indique n'existe pas sur ce serveur.").await; + return true; + } + + let delay_raw = modal_value(modal, ANCIEN_DELAY_INPUT_ID).unwrap_or_default(); + let Some(delay_seconds) = parse_delay_seconds(&delay_raw) else { + respond_ephemeral_modal( + ctx, + modal, + "Delai invalide. Exemples valides: 30j, 72h, 90m.", + ) + .await; + return true; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let current = db::get_or_create_old_member_settings(&pool, bot_id, guild_id.get() as i64) + .await + .ok(); + + let Some(current) = current else { + return true; + }; + + let updated = db::update_old_member_settings( + &pool, + bot_id, + guild_id.get() as i64, + Some(role_id.get() as i64), + delay_seconds, + current.enabled, + ) + .await + .ok(); + + if let Some(updated) = updated { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .embed(ancien_embed(&updated).color(theme_color(ctx).await)) + .components(ancien_components(modal.user.id, &updated)) + .ephemeral(true), + ), + ) + .await; + } + + true +} + +pub async fn maybe_assign_ancien_role(ctx: &Context, guild_id: GuildId, user_id: UserId) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let settings = db::get_or_create_old_member_settings(&pool, bot_id, guild_id.get() as i64) + .await + .ok(); + + let Some(settings) = settings else { + return; + }; + + if !settings.enabled { + return; + } + + let Some(role_raw) = settings.role_id else { + return; + }; + + let Ok(role_id_u64) = u64::try_from(role_raw) else { + return; + }; + + let role_id = RoleId::new(role_id_u64); + + let Ok(member) = guild_id.member(&ctx.http, user_id).await else { + return; + }; + + if member.user.bot || member.roles.contains(&role_id) { + return; + } + + let joined_at = member.joined_at.unwrap_or_else(|| member.user.created_at()); + let elapsed = Utc::now() + .timestamp() + .saturating_sub(joined_at.unix_timestamp()); + + if elapsed < settings.delay_seconds.max(1) { + return; + } + + let _ = member.add_role(&ctx.http, role_id).await; +} + +pub struct AncienCommand; +pub static COMMAND_DESCRIPTOR: AncienCommand = AncienCommand; + +impl crate::commands::command_contract::CommandSpec for AncienCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "ancien", + category: "roles", + params: "aucun", + description: "Definit au bout de combien de temps un membre est considere comme ancien et recoit le role configure.", + examples: &["+ancien", "+help ancien"], + default_aliases: &[], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/roles/derank.rs b/src/commands/roles/derank.rs index 73ab339..ae715cf 100644 --- a/src/commands/roles/derank.rs +++ b/src/commands/roles/derank.rs @@ -1,9 +1,13 @@ +use std::collections::HashSet; + use serenity::builder::CreateEmbed; use serenity::model::prelude::*; use serenity::prelude::*; +use crate::commands::automod_service::pool; use crate::commands::common::{send_embed, theme_color}; use crate::commands::moderation_sanction_helpers::parse_targets; +use crate::db; pub async fn handle_derank(ctx: &Context, msg: &Message, args: &[&str]) { let Some(guild_id) = msg.guild_id else { @@ -18,12 +22,27 @@ pub async fn handle_derank(ctx: &Context, msg: &Message, args: &[&str]) { return; } + let protected_roles: HashSet = if let Some(pool) = pool(ctx).await { + let bot_id = ctx.cache.current_user().id.get() as i64; + db::list_noderank_roles(&pool, bot_id, guild_id.get() as i64) + .await + .unwrap_or_default() + .into_iter() + .filter_map(|id| u64::try_from(id).ok()) + .collect() + } else { + HashSet::new() + }; + let mut done = 0usize; for uid in &targets { if let Ok(member) = guild_id.member(&ctx.http, *uid).await { let roles = member.roles.clone(); let mut ok = true; for role_id in roles { + if protected_roles.contains(&role_id.get()) { + continue; + } if member.remove_role(&ctx.http, role_id).await.is_err() { ok = false; } diff --git a/src/commands/roles/noderank.rs b/src/commands/roles/noderank.rs new file mode 100644 index 0000000..994f933 --- /dev/null +++ b/src/commands/roles/noderank.rs @@ -0,0 +1,101 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::*; +use serenity::prelude::*; + +use crate::commands::automod_service::pool; +use crate::commands::common::{parse_role, send_embed}; +use crate::db; + +pub async fn handle_noderank(ctx: &Context, msg: &Message, args: &[&str]) { + let Some(guild_id) = msg.guild_id else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + let bot_id = ctx.cache.current_user().id.get() as i64; + let guild_id_raw = guild_id.get() as i64; + + if args.is_empty() { + let roles = db::list_noderank_roles(&pool, bot_id, guild_id_raw) + .await + .unwrap_or_default(); + let description = if roles.is_empty() { + "Aucun role protege.".to_string() + } else { + roles + .iter() + .map(|role_id| format!("<@&{}>", role_id)) + .collect::>() + .join("\n") + }; + + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("NoDeRank") + .description(description) + .color(0x5865F2), + ) + .await; + return; + } + + if args.len() < 2 { + return; + } + + let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else { + return; + }; + let Some(role) = parse_role(&guild, args[1]) else { + return; + }; + + if args[0].eq_ignore_ascii_case("add") { + let _ = db::add_noderank_role(&pool, bot_id, guild_id_raw, role.id.get() as i64).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("NoDeRank") + .description(format!("Role protege ajoute: <@&{}>", role.id.get())) + .color(0x57F287), + ) + .await; + return; + } + + if args[0].eq_ignore_ascii_case("del") || args[0].eq_ignore_ascii_case("remove") { + let _ = db::remove_noderank_role(&pool, bot_id, guild_id_raw, role.id.get() as i64).await; + send_embed( + ctx, + msg, + CreateEmbed::new() + .title("NoDeRank") + .description(format!("Role protege retire: <@&{}>", role.id.get())) + .color(0x57F287), + ) + .await; + } +} + +pub struct NoderankCommand; +pub static COMMAND_DESCRIPTOR: NoderankCommand = NoderankCommand; + +impl crate::commands::command_contract::CommandSpec for NoderankCommand { + fn metadata(&self) -> crate::commands::command_contract::CommandMetadata { + crate::commands::command_contract::CommandMetadata { + name: "noderank", + category: "roles", + params: "[add/del <@role/ID/nom>]", + description: "Definit des roles proteges qui ne sont pas retires par +derank.", + examples: &["+noderank", "+noderank add @VIP", "+noderank del @VIP"], + default_aliases: &["ndr"], + allow_in_dm: false, + default_permission: 8, + } + } +} diff --git a/src/commands/salons_vocal/tempvoc.rs b/src/commands/salons_vocal/tempvoc.rs index de863a8..d900a56 100644 --- a/src/commands/salons_vocal/tempvoc.rs +++ b/src/commands/salons_vocal/tempvoc.rs @@ -1,18 +1,60 @@ use chrono::Utc; +use serenity::all::{ + ActionRowComponent, ButtonStyle, Channel, ChannelId, ChannelType, ComponentInteraction, + GuildId, InputTextStyle, Message, MessageId, ModalInteraction, PermissionOverwrite, + PermissionOverwriteType, Permissions, RoleId, User, UserId, VoiceState, +}; use serenity::builder::{ CreateActionRow, CreateButton, CreateChannel, CreateEmbed, CreateInputText, CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage, CreateModal, + EditChannel, EditMessage, }; use serenity::model::Colour; -use serenity::model::application::{ - ActionRowComponent, ButtonStyle, ComponentInteraction, InputTextStyle, ModalInteraction, -}; -use serenity::model::prelude::*; use serenity::prelude::*; +use std::collections::HashSet; use crate::db; const TEMPVOC_MENU: &str = "tempvoc:settings"; +const TEMPVOC_ROOM_SCOPE: &str = "room"; +const TEMPVOC_MODAL_SCOPE: &str = "modal"; +const MEMBER_LIST_INPUT_ID: &str = "members"; +const TRANSFER_OWNER_INPUT_ID: &str = "new_owner"; +const SETTINGS_NAME_INPUT_ID: &str = "room_name"; +const SETTINGS_LIMIT_INPUT_ID: &str = "user_limit"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RoomMode { + Open, + Closed, + Private, +} + +impl RoomMode { + fn from_db(value: &str) -> Self { + match value { + "closed" => Self::Closed, + "private" => Self::Private, + _ => Self::Open, + } + } + + fn as_db(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + Self::Private => "private", + } + } + + fn label(&self) -> &'static str { + match self { + Self::Open => "Ouvert", + Self::Closed => "Ferme", + Self::Private => "Prive", + } + } +} fn parse_owner_id(custom_id: &str) -> Option<(String, u64)> { let mut parts = custom_id.rsplitn(2, ':'); @@ -21,6 +63,38 @@ fn parse_owner_id(custom_id: &str) -> Option<(String, u64)> { Some((action, owner)) } +fn parse_scoped_channel_id(custom_id: &str, expected_scope: &str) -> Option<(String, ChannelId)> { + let mut parts = custom_id.split(':'); + let namespace = parts.next()?; + let scope = parts.next()?; + let action = parts.next()?.to_string(); + let channel_id = parts.next()?.parse::().ok()?; + + if namespace != "tempvoc" || scope != expected_scope || parts.next().is_some() { + return None; + } + + Some((action, ChannelId::new(channel_id))) +} + +fn room_button_id(action: &str, channel_id: ChannelId) -> String { + format!( + "tempvoc:{}:{}:{}", + TEMPVOC_ROOM_SCOPE, + action, + channel_id.get() + ) +} + +fn room_modal_id(action: &str, channel_id: ChannelId) -> String { + format!( + "tempvoc:{}:{}:{}", + TEMPVOC_MODAL_SCOPE, + action, + channel_id.get() + ) +} + fn modal_value(modal: &ModalInteraction, wanted_id: &str) -> Option { for row in &modal.data.components { for component in &row.components { @@ -34,10 +108,115 @@ fn modal_value(modal: &ModalInteraction, wanted_id: &str) -> Option { None } +fn decode_member_list(raw: &str) -> Vec { + serde_json::from_str::>(raw).unwrap_or_default() +} + +fn encode_member_list(ids: &[u64]) -> String { + serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string()) +} + +fn normalize_member_list(ids: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for id in ids { + if id == 0 || !seen.insert(id) { + continue; + } + out.push(id); + } + + out.sort_unstable(); + out +} + +fn parse_user_ids_input(input: &str) -> Vec { + let parsed = input + .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ';') + .filter_map(|chunk| { + let digits: String = chunk.chars().filter(|ch| ch.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + digits.parse::().ok() + }) + .collect::>(); + + normalize_member_list(parsed) +} + +fn format_member_mentions(ids: &[u64]) -> String { + if ids.is_empty() { + return "Aucun".to_string(); + } + + ids.iter() + .map(|id| format!("<@{}>", id)) + .collect::>() + .join(", ") +} + +fn normalize_room_name(input: &str) -> String { + let compact = input.split_whitespace().collect::>().join(" "); + compact.trim().chars().take(100).collect::() +} + +fn default_room_name(user: &User) -> String { + normalize_room_name(&format!("๐Ÿ”Š Salon de {}", user.name)) +} + +async fn unique_voice_name( + ctx: &Context, + guild_id: GuildId, + requested_name: &str, + ignored_channel_id: Option, +) -> Option { + let requested_name = normalize_room_name(requested_name); + if requested_name.is_empty() { + return None; + } + + let Ok(channels) = guild_id.channels(&ctx.http).await else { + return Some(requested_name); + }; + + let existing_names = channels + .values() + .filter(|channel| { + channel.kind == ChannelType::Voice + && ignored_channel_id + .map(|ignored| ignored != channel.id) + .unwrap_or(true) + }) + .map(|channel| channel.name.to_lowercase()) + .collect::>(); + + if !existing_names.contains(&requested_name.to_lowercase()) { + return Some(requested_name); + } + + for suffix in 2..5000 { + let suffix_text = format!(" {}", suffix); + let max_base_len = 100usize.saturating_sub(suffix_text.chars().count()); + let base = requested_name + .chars() + .take(max_base_len) + .collect::(); + let candidate = format!("{}{}", base, suffix_text); + + if !existing_names.contains(&candidate.to_lowercase()) { + return Some(candidate); + } + } + + Some(requested_name) +} + fn tempvoc_embed(settings: &db::TempvocSettings) -> CreateEmbed { let mut embed = CreateEmbed::new() .title("Tempvoc") - .description("Gรจre les vocaux temporaires du serveur.") + .description("Gere les vocaux temporaires du serveur.") .colour(Colour::from_rgb(100, 180, 255)) .timestamp(Utc::now()) .field( @@ -47,19 +226,22 @@ fn tempvoc_embed(settings: &db::TempvocSettings) -> CreateEmbed { ); if let Some(trigger) = settings.trigger_channel_id { - embed = embed.field("Canal dรฉclencheur", format!("<#{}>", trigger), true); + embed = embed.field("Canal declencheur", format!("<#{}>", trigger), true); } if let Some(category) = settings.category_id { - embed = embed.field("Catรฉgorie", format!("<#{}>", category), true); + embed = embed.field("Categorie", format!("<#{}>", category), true); } embed } -fn tempvoc_components(owner_id: UserId, settings: &db::TempvocSettings) -> Vec { +fn tempvoc_settings_components( + owner_id: UserId, + settings: &db::TempvocSettings, +) -> Vec { let toggle_label = if settings.enabled { - "Dรฉsactiver" + "Desactiver" } else { "Activer" }; @@ -72,11 +254,165 @@ fn tempvoc_components(owner_id: UserId, settings: &db::TempvocSettings) -> Vec) -> CreateEmbed { + let mode = RoomMode::from_db(&room.voice_mode); + let whitelist = decode_member_list(&room.whitelist_json); + let blacklist = decode_member_list(&room.blacklist_json); + let limit_label = if room.user_limit <= 0 { + "Illimite".to_string() + } else { + room.user_limit.to_string() + }; + + let options = format!( + "Micro: {}\nCamera: {}\nSoundboard: {}", + if room.allow_micro { + "Autorise" + } else { + "Bloque" + }, + if room.allow_camera { + "Autorisee" + } else { + "Bloquee" + }, + if room.allow_soundboard { + "Autorise" + } else { + "Bloque" + }, + ); + + let mut embed = CreateEmbed::new() + .title("Configuration du vocal temporaire") + .description("Voici l'espace de configuration de ton salon vocal temporaire. Les options disponibles te permettent de personnaliser les permissions de ton salon selon tes preferences.") + .colour(Colour::from_rgb(46, 204, 113)) + .timestamp(Utc::now()) + .field( + "๐Ÿ”“ Ouvert", + "Le salon est accessible a tous les membres, sauf ceux en blacklist.", + false, + ) + .field( + "๐Ÿ”’ Ferme", + "Le salon est visible pour tous, mais seulement accessible aux membres whitelist.", + false, + ) + .field( + "๐Ÿ™ˆ Prive", + "Le salon est visible et accessible uniquement pour les membres whitelist.", + false, + ) + .field("โœ… Whitelist", format_member_mentions(&whitelist), false) + .field("โ›” Blacklist", format_member_mentions(&blacklist), false) + .field( + "๐Ÿงน Purge", + "Deconnecte tous les membres qui ne sont pas owner ou whitelist.", + false, + ) + .field( + "๐Ÿ‘‘ Owner", + "Transfere la gestion du vocal a un membre de ton choix.", + false, + ) + .field( + "Etat actuel", + format!( + "Mode: {}\nOwner: <@{}>\nLimite: {}", + mode.label(), room.owner_id, limit_label + ), + false, + ) + .field("Options", options, false) + .field( + "๐Ÿ’ก Astuce", + "Les membres whitelist ne sont pas impactes par les restrictions de mode.", + false, + ); + + if let Some(room_name) = &room.room_name { + embed = embed.field("Nom par defaut", room_name, false); + } + + if let Some(notice) = notice { + embed = embed.field("Mise a jour", notice, false); + } + + embed +} + +fn mode_button_style(current_mode: RoomMode, expected_mode: RoomMode) -> ButtonStyle { + if current_mode == expected_mode { + ButtonStyle::Success + } else { + ButtonStyle::Secondary + } +} + +fn toggle_button_style(active: bool) -> ButtonStyle { + if active { + ButtonStyle::Success + } else { + ButtonStyle::Danger + } +} + +fn tempvoc_room_components(channel_id: ChannelId, room: &db::TempvocRoom) -> Vec { + let mode = RoomMode::from_db(&room.voice_mode); + + vec![ + CreateActionRow::Buttons(vec![ + CreateButton::new(room_button_id("open", channel_id)) + .label("Ouvrir") + .style(mode_button_style(mode, RoomMode::Open)), + CreateButton::new(room_button_id("closed", channel_id)) + .label("Fermer") + .style(mode_button_style(mode, RoomMode::Closed)), + CreateButton::new(room_button_id("private", channel_id)) + .label("Prive") + .style(mode_button_style(mode, RoomMode::Private)), + ]), + CreateActionRow::Buttons(vec![ + CreateButton::new(room_button_id("whitelist", channel_id)) + .label("Whitelist") + .style(ButtonStyle::Primary), + CreateButton::new(room_button_id("blacklist", channel_id)) + .label("Blacklist") + .style(ButtonStyle::Danger), + CreateButton::new(room_button_id("purge", channel_id)) + .label("Purge") + .style(ButtonStyle::Secondary), + ]), + CreateActionRow::Buttons(vec![ + CreateButton::new(room_button_id("micro", channel_id)) + .label("Micro") + .style(toggle_button_style(room.allow_micro)), + CreateButton::new(room_button_id("camera", channel_id)) + .label("Camera") + .style(toggle_button_style(room.allow_camera)), + CreateButton::new(room_button_id("soundboard", channel_id)) + .label("Soundboard") + .style(toggle_button_style(room.allow_soundboard)), + ]), + CreateActionRow::Buttons(vec![ + CreateButton::new(room_button_id("transfer", channel_id)) + .label("Transferer l'owner") + .style(ButtonStyle::Secondary), + CreateButton::new(room_button_id("settings", channel_id)) + .label("Settings") + .style(ButtonStyle::Secondary), + CreateButton::new(room_button_id("save", channel_id)) + .label("Save") + .style(ButtonStyle::Primary), + ]), + ] +} + async fn pool(ctx: &Context) -> Option { let data = ctx.data.read().await; data.get::().cloned() @@ -100,7 +436,7 @@ async fn show_menu(ctx: &Context, msg: &Message) { trigger_channel_id: None, category_id: None, enabled: false, - updated_at: chrono::Utc::now(), + updated_at: Utc::now(), }); let _ = msg @@ -109,7 +445,7 @@ async fn show_menu(ctx: &Context, msg: &Message) { &ctx.http, CreateMessage::new() .embed(tempvoc_embed(&settings)) - .components(tempvoc_components(msg.author.id, &settings)), + .components(tempvoc_settings_components(msg.author.id, &settings)), ) .await; } @@ -118,7 +454,398 @@ pub async fn handle_tempvoc(ctx: &Context, msg: &Message, _args: &[&str]) { show_menu(ctx, msg).await; } -pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { +async fn respond_ephemeral_component( + ctx: &Context, + component: &ComponentInteraction, + content: &str, +) { + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +async fn respond_ephemeral_modal(ctx: &Context, modal: &ModalInteraction, content: &str) { + let _ = modal + .create_response( + &ctx.http, + CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(content) + .ephemeral(true), + ), + ) + .await; +} + +fn normalize_room_lists(room: &mut db::TempvocRoom) { + let mut whitelist = normalize_member_list(decode_member_list(&room.whitelist_json)); + let mut blacklist = normalize_member_list(decode_member_list(&room.blacklist_json)); + + whitelist.retain(|id| *id as i64 != room.owner_id); + blacklist.retain(|id| *id as i64 != room.owner_id); + whitelist.retain(|id| !blacklist.contains(id)); + + room.whitelist_json = encode_member_list(&whitelist); + room.blacklist_json = encode_member_list(&blacklist); +} + +fn mode_permissions(mode: RoomMode) -> (Permissions, Permissions) { + match mode { + RoomMode::Open => ( + Permissions::VIEW_CHANNEL.union(Permissions::CONNECT), + Permissions::empty(), + ), + RoomMode::Closed => (Permissions::VIEW_CHANNEL, Permissions::CONNECT), + RoomMode::Private => ( + Permissions::empty(), + Permissions::VIEW_CHANNEL.union(Permissions::CONNECT), + ), + } +} + +async fn apply_room_permissions(ctx: &Context, room: &db::TempvocRoom) -> bool { + let guild_id = GuildId::new(room.guild_id as u64); + let channel_id = ChannelId::new(room.channel_id as u64); + + let Ok(channel) = channel_id.to_channel(&ctx.http).await else { + return false; + }; + + let Channel::Guild(guild_channel) = channel else { + return false; + }; + + let everyone_role = RoleId::new(guild_id.get()); + let mut overwrites = guild_channel.permission_overwrites.clone(); + overwrites.retain(|overwrite| match overwrite.kind { + PermissionOverwriteType::Role(role_id) => role_id != everyone_role, + PermissionOverwriteType::Member(_) => false, + _ => true, + }); + + let mode = RoomMode::from_db(&room.voice_mode); + let (mut everyone_allow, mut everyone_deny) = mode_permissions(mode); + + if room.allow_micro { + everyone_allow |= Permissions::SPEAK; + } else { + everyone_deny |= Permissions::SPEAK; + } + + if room.allow_camera { + everyone_allow |= Permissions::STREAM; + } else { + everyone_deny |= Permissions::STREAM; + } + + if room.allow_soundboard { + everyone_allow |= Permissions::USE_SOUNDBOARD; + } else { + everyone_deny |= Permissions::USE_SOUNDBOARD; + } + + overwrites.push(PermissionOverwrite { + allow: everyone_allow, + deny: everyone_deny, + kind: PermissionOverwriteType::Role(everyone_role), + }); + + let owner_id = UserId::new(room.owner_id as u64); + overwrites.push(PermissionOverwrite { + allow: Permissions::VIEW_CHANNEL + .union(Permissions::CONNECT) + .union(Permissions::SPEAK) + .union(Permissions::STREAM) + .union(Permissions::USE_SOUNDBOARD), + deny: Permissions::empty(), + kind: PermissionOverwriteType::Member(owner_id), + }); + + for member_id in decode_member_list(&room.blacklist_json) { + if member_id as i64 == room.owner_id { + continue; + } + + overwrites.push(PermissionOverwrite { + allow: Permissions::empty(), + deny: Permissions::VIEW_CHANNEL.union(Permissions::CONNECT), + kind: PermissionOverwriteType::Member(UserId::new(member_id)), + }); + } + + for member_id in decode_member_list(&room.whitelist_json) { + if member_id as i64 == room.owner_id { + continue; + } + + overwrites.push(PermissionOverwrite { + allow: Permissions::VIEW_CHANNEL.union(Permissions::CONNECT), + deny: Permissions::empty(), + kind: PermissionOverwriteType::Member(UserId::new(member_id)), + }); + } + + let limit = room.user_limit.clamp(0, 99) as u32; + channel_id + .edit( + &ctx.http, + EditChannel::new().permissions(overwrites).user_limit(limit), + ) + .await + .is_ok() +} + +async fn refresh_room_panel_message(ctx: &Context, room: &db::TempvocRoom, notice: Option<&str>) { + let Some(control_channel_id) = room.control_message_channel_id else { + return; + }; + + let Some(control_message_id) = room.control_message_id else { + return; + }; + + let _ = ChannelId::new(control_channel_id as u64) + .edit_message( + &ctx.http, + MessageId::new(control_message_id as u64), + EditMessage::new() + .content(format!("<@{}>", room.owner_id)) + .embed(tempvoc_room_embed(room, notice)) + .components(tempvoc_room_components( + ChannelId::new(room.channel_id as u64), + room, + )), + ) + .await; +} + +async fn persist_room_state( + ctx: &Context, + pool: &sqlx::PgPool, + room: &mut db::TempvocRoom, + notice: Option<&str>, +) -> bool { + normalize_room_lists(room); + + let Ok(updated_room) = db::save_tempvoc_room_state(pool, room).await else { + return false; + }; + + *room = updated_room; + let _ = apply_room_permissions(ctx, room).await; + refresh_room_panel_message(ctx, room, notice).await; + true +} + +async fn send_room_panel(ctx: &Context, pool: &sqlx::PgPool, room: &mut db::TempvocRoom) { + let channel_id = ChannelId::new(room.channel_id as u64); + + let Ok(message) = channel_id + .send_message( + &ctx.http, + CreateMessage::new() + .content(format!("<@{}>", room.owner_id)) + .embed(tempvoc_room_embed(room, None)) + .components(tempvoc_room_components(channel_id, room)), + ) + .await + else { + return; + }; + + if let Ok(updated_room) = db::set_tempvoc_room_control_message( + pool, + room.channel_id, + message.channel_id.get() as i64, + message.id.get() as i64, + ) + .await + { + *room = updated_room; + } +} + +async fn create_temp_channel( + ctx: &Context, + guild_id: GuildId, + user: &User, + settings: &db::TempvocSettings, +) { + let Some(trigger_channel_id) = settings.trigger_channel_id else { + return; + }; + + let Ok(trigger_channel) = ChannelId::new(trigger_channel_id as u64) + .to_channel(&ctx.http) + .await + else { + return; + }; + + let Channel::Guild(trigger) = trigger_channel else { + return; + }; + + let Some(pool) = pool(ctx).await else { + return; + }; + + let profile = db::get_or_create_tempvoc_profile( + &pool, + settings.bot_id, + settings.guild_id, + user.id.get() as i64, + ) + .await + .unwrap_or(db::TempvocProfile { + bot_id: settings.bot_id, + guild_id: settings.guild_id, + user_id: user.id.get() as i64, + voice_mode: RoomMode::Open.as_db().to_string(), + allow_micro: true, + allow_camera: true, + allow_soundboard: true, + user_limit: 0, + room_name: Some(default_room_name(user)), + updated_at: Utc::now(), + }); + + let base_name = profile + .room_name + .as_deref() + .map(normalize_room_name) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| default_room_name(user)); + + let Some(unique_name) = unique_voice_name(ctx, guild_id, &base_name, None).await else { + return; + }; + + let category_id = settings + .category_id + .map(|value| ChannelId::new(value as u64)) + .or(trigger.parent_id); + + let mut builder = CreateChannel::new(unique_name) + .kind(ChannelType::Voice) + .permissions(trigger.permission_overwrites.clone()); + + if let Some(category_id) = category_id { + builder = builder.category(category_id); + } + + if profile.user_limit > 0 { + builder = builder.user_limit(profile.user_limit as u32); + } + + let Ok(channel) = guild_id.create_channel(&ctx.http, builder).await else { + return; + }; + + if guild_id + .move_member(&ctx.http, user.id, channel.id) + .await + .is_err() + { + let _ = channel.delete(&ctx.http).await; + return; + } + + let voice_mode = RoomMode::from_db(&profile.voice_mode).as_db().to_string(); + let user_limit = profile.user_limit.clamp(0, 99); + let room_name = if base_name.is_empty() { + None + } else { + Some(base_name.as_str()) + }; + + let Ok(mut room) = db::create_tempvoc_room( + &pool, + settings.bot_id, + settings.guild_id, + channel.id.get() as i64, + user.id.get() as i64, + &voice_mode, + profile.allow_micro, + profile.allow_camera, + profile.allow_soundboard, + user_limit, + room_name, + ) + .await + else { + let _ = channel.delete(&ctx.http).await; + return; + }; + + let _ = apply_room_permissions(ctx, &room).await; + send_room_panel(ctx, &pool, &mut room).await; +} + +async fn delete_temp_channel(ctx: &Context, channel_id: ChannelId) { + if let Ok(channel) = channel_id.to_channel(&ctx.http).await { + if let Channel::Guild(guild_channel) = channel { + let _ = guild_channel.delete(&ctx.http).await; + } + } +} + +async fn cached_room_members(ctx: &Context, guild_id: GuildId, channel_id: ChannelId) -> usize { + ctx.cache + .guild(guild_id) + .map(|guild| { + guild + .voice_states + .values() + .filter(|state| state.channel_id == Some(channel_id)) + .count() + }) + .unwrap_or(0) +} + +async fn purge_room_members( + ctx: &Context, + guild_id: GuildId, + channel_id: ChannelId, + owner_id: u64, + whitelist: &[u64], +) -> usize { + let mut allowed = HashSet::new(); + allowed.insert(owner_id); + for member_id in whitelist { + allowed.insert(*member_id); + } + + let mut to_disconnect = Vec::new(); + if let Some(guild) = ctx.cache.guild(guild_id) { + for (user_id, voice_state) in &guild.voice_states { + if voice_state.channel_id == Some(channel_id) && !allowed.contains(&user_id.get()) { + to_disconnect.push(*user_id); + } + } + } + + let mut disconnected = 0usize; + for user_id in to_disconnect { + if guild_id.disconnect_member(&ctx.http, user_id).await.is_ok() { + disconnected += 1; + } + } + + disconnected +} + +async fn handle_settings_component_interaction( + ctx: &Context, + component: &ComponentInteraction, +) -> bool { if !component.data.custom_id.starts_with(TEMPVOC_MENU) { return false; } @@ -128,16 +855,7 @@ pub async fn handle_component_interaction(ctx: &Context, component: &ComponentIn }; if component.user.id.get() != owner_id { - let _ = component - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Seul l'auteur du menu peut l'utiliser.") - .ephemeral(true), - ), - ) - .await; + respond_ephemeral_component(ctx, component, "Seul l'auteur du menu peut l'utiliser.").await; return true; } @@ -164,13 +882,13 @@ pub async fn handle_component_interaction(ctx: &Context, component: &ComponentIn CreateActionRow::InputText( CreateInputText::new( InputTextStyle::Short, - "Canal dรฉclencheur", + "Canal declencheur", "trigger_channel_id", ) .required(false), ), CreateActionRow::InputText( - CreateInputText::new(InputTextStyle::Short, "Catรฉgorie", "category_id") + CreateInputText::new(InputTextStyle::Short, "Categorie", "category_id") .required(false), ), ]); @@ -200,7 +918,7 @@ pub async fn handle_component_interaction(ctx: &Context, component: &ComponentIn CreateInteractionResponse::UpdateMessage( CreateInteractionResponseMessage::new() .embed(tempvoc_embed(&updated)) - .components(tempvoc_components(component.user.id, &updated)), + .components(tempvoc_settings_components(component.user.id, &updated)), ), ) .await; @@ -216,7 +934,7 @@ pub async fn handle_component_interaction(ctx: &Context, component: &ComponentIn CreateInteractionResponse::UpdateMessage( CreateInteractionResponseMessage::new() .embed(tempvoc_embed(&settings)) - .components(tempvoc_components(component.user.id, &settings)), + .components(tempvoc_settings_components(component.user.id, &settings)), ), ) .await; @@ -226,7 +944,261 @@ pub async fn handle_component_interaction(ctx: &Context, component: &ComponentIn false } -pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { +async fn handle_room_component_interaction( + ctx: &Context, + component: &ComponentInteraction, +) -> bool { + let Some((action, channel_id)) = + parse_scoped_channel_id(&component.data.custom_id, TEMPVOC_ROOM_SCOPE) + else { + return false; + }; + + let Some(pool) = pool(ctx).await else { + return true; + }; + + let Some(mut room) = db::get_tempvoc_room_by_channel(&pool, channel_id.get() as i64) + .await + .ok() + .flatten() + else { + respond_ephemeral_component(ctx, component, "Ce panel tempvoc n'est plus actif.").await; + return true; + }; + + if component.user.id.get() as i64 != room.owner_id { + respond_ephemeral_component( + ctx, + component, + "Seul l'owner du vocal temporaire peut utiliser ce panel.", + ) + .await; + return true; + } + + room.control_message_channel_id = Some(component.message.channel_id.get() as i64); + room.control_message_id = Some(component.message.id.get() as i64); + + match action.as_str() { + "open" | "closed" | "private" => { + room.voice_mode = action.clone(); + + if !persist_room_state(ctx, &pool, &mut room, None).await { + respond_ephemeral_component( + ctx, + component, + "Impossible de mettre a jour ce vocal.", + ) + .await; + return true; + } + + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(tempvoc_room_embed(&room, None)) + .components(tempvoc_room_components(channel_id, &room)), + ), + ) + .await; + return true; + } + "whitelist" => { + let modal = CreateModal::new( + room_modal_id("whitelist", channel_id), + "Modifier la whitelist", + ) + .components(vec![CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Paragraph, + "Membres (mentions/IDs, vide pour effacer)", + MEMBER_LIST_INPUT_ID, + ) + .required(false), + )]); + + let _ = component + .create_response(&ctx.http, CreateInteractionResponse::Modal(modal)) + .await; + return true; + } + "blacklist" => { + let modal = CreateModal::new( + room_modal_id("blacklist", channel_id), + "Modifier la blacklist", + ) + .components(vec![CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Paragraph, + "Membres (mentions/IDs, vide pour effacer)", + MEMBER_LIST_INPUT_ID, + ) + .required(false), + )]); + + let _ = component + .create_response(&ctx.http, CreateInteractionResponse::Modal(modal)) + .await; + return true; + } + "purge" => { + let whitelist = decode_member_list(&room.whitelist_json); + let purged = purge_room_members( + ctx, + GuildId::new(room.guild_id as u64), + channel_id, + room.owner_id as u64, + &whitelist, + ) + .await; + + let notice = format!("{} membre(s) deconnecte(s).", purged); + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(tempvoc_room_embed(&room, Some(¬ice))) + .components(tempvoc_room_components(channel_id, &room)), + ), + ) + .await; + + refresh_room_panel_message(ctx, &room, Some(¬ice)).await; + return true; + } + "micro" => { + room.allow_micro = !room.allow_micro; + } + "camera" => { + room.allow_camera = !room.allow_camera; + } + "soundboard" => { + room.allow_soundboard = !room.allow_soundboard; + } + "transfer" => { + let modal = + CreateModal::new(room_modal_id("transfer", channel_id), "Transferer l'owner") + .components(vec![CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Short, + "Nouveau owner (mention ou ID)", + TRANSFER_OWNER_INPUT_ID, + ) + .required(true), + )]); + + let _ = component + .create_response(&ctx.http, CreateInteractionResponse::Modal(modal)) + .await; + return true; + } + "settings" => { + let modal = + CreateModal::new(room_modal_id("settings", channel_id), "Parametres du salon") + .components(vec![ + CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Short, + "Nom du salon", + SETTINGS_NAME_INPUT_ID, + ) + .required(false), + ), + CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Short, + "Limite (0 a 99)", + SETTINGS_LIMIT_INPUT_ID, + ) + .required(false), + ), + ]); + + let _ = component + .create_response(&ctx.http, CreateInteractionResponse::Modal(modal)) + .await; + return true; + } + "save" => { + let save_result = db::save_tempvoc_profile( + &pool, + room.bot_id, + room.guild_id, + room.owner_id, + RoomMode::from_db(&room.voice_mode).as_db(), + room.allow_micro, + room.allow_camera, + room.allow_soundboard, + room.user_limit, + room.room_name.as_deref(), + ) + .await; + + let notice = if save_result.is_ok() { + "Configuration sauvegardee comme profil par defaut." + } else { + "Echec de sauvegarde du profil par defaut." + }; + + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(tempvoc_room_embed(&room, Some(notice))) + .components(tempvoc_room_components(channel_id, &room)), + ), + ) + .await; + + refresh_room_panel_message(ctx, &room, Some(notice)).await; + return true; + } + _ => { + return false; + } + } + + if !persist_room_state(ctx, &pool, &mut room, None).await { + respond_ephemeral_component(ctx, component, "Impossible de mettre a jour ce vocal.").await; + return true; + } + + let _ = component + .create_response( + &ctx.http, + CreateInteractionResponse::UpdateMessage( + CreateInteractionResponseMessage::new() + .embed(tempvoc_room_embed(&room, None)) + .components(tempvoc_room_components(channel_id, &room)), + ), + ) + .await; + + true +} + +pub async fn handle_component_interaction(ctx: &Context, component: &ComponentInteraction) -> bool { + if component.data.custom_id.starts_with(TEMPVOC_MENU) { + return handle_settings_component_interaction(ctx, component).await; + } + + if component + .data + .custom_id + .starts_with(&format!("tempvoc:{}:", TEMPVOC_ROOM_SCOPE)) + { + return handle_room_component_interaction(ctx, component).await; + } + + false +} + +async fn handle_settings_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { if !modal.data.custom_id.starts_with(TEMPVOC_MENU) { return false; } @@ -236,16 +1208,12 @@ pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) - }; if modal.user.id.get() != owner_id { - let _ = modal - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content("Seul l'auteur du menu peut soumettre ce formulaire.") - .ephemeral(true), - ), - ) - .await; + respond_ephemeral_modal( + ctx, + modal, + "Seul l'auteur du menu peut soumettre ce formulaire.", + ) + .await; return true; } @@ -285,7 +1253,7 @@ pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) - CreateInteractionResponse::Message( CreateInteractionResponseMessage::new() .embed(tempvoc_embed(&updated)) - .components(tempvoc_components(modal.user.id, &updated)) + .components(tempvoc_settings_components(modal.user.id, &updated)) .ephemeral(true), ), ) @@ -295,105 +1263,163 @@ pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) - true } -fn sanitize_voice_name(input: &str) -> String { - let mut out = String::new(); - let mut previous_dash = false; - - for ch in input.to_lowercase().chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch); - previous_dash = false; - } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !previous_dash { - out.push('-'); - previous_dash = true; - } - } - - out.trim_matches('-').to_string() -} - -async fn cached_room_members(ctx: &Context, guild_id: GuildId, channel_id: ChannelId) -> usize { - ctx.cache - .guild(guild_id) - .map(|guild| { - guild - .voice_states - .values() - .filter(|state| state.channel_id == Some(channel_id)) - .count() - }) - .unwrap_or(0) -} - -async fn create_temp_channel( - ctx: &Context, - guild_id: GuildId, - user: &User, - settings: &db::TempvocSettings, -) { - let Some(trigger_channel_id) = settings.trigger_channel_id else { - return; - }; - - let Ok(trigger_channel) = ChannelId::new(trigger_channel_id as u64) - .to_channel(&ctx.http) - .await +async fn handle_room_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { + let Some((action, channel_id)) = + parse_scoped_channel_id(&modal.data.custom_id, TEMPVOC_MODAL_SCOPE) else { - return; + return false; }; - let Channel::Guild(trigger) = trigger_channel else { - return; + let Some(pool) = pool(ctx).await else { + return true; }; - let category_id = settings - .category_id - .map(|value| ChannelId::new(value as u64)) - .or(trigger.parent_id); - let name = sanitize_voice_name(&user.name); - if name.is_empty() { - return; - } - - let mut builder = CreateChannel::new(format!("๐ŸŽค {}", name)) - .kind(ChannelType::Voice) - .permissions(trigger.permission_overwrites.clone()); - - if let Some(category_id) = category_id { - builder = builder.category(category_id); - } - - let Ok(channel) = guild_id.create_channel(&ctx.http, builder).await else { - return; - }; - - if guild_id - .move_member(&ctx.http, user.id, channel.id) + let Some(mut room) = db::get_tempvoc_room_by_channel(&pool, channel_id.get() as i64) .await - .is_err() - { - let _ = channel.delete(&ctx.http).await; - return; - } + .ok() + .flatten() + else { + respond_ephemeral_modal(ctx, modal, "Ce vocal temporaire n'est plus actif.").await; + return true; + }; - if let Some(pool) = pool(ctx).await { - let _ = db::create_tempvoc_room( - &pool, - settings.bot_id, - settings.guild_id, - channel.id.get() as i64, - user.id.get() as i64, + if modal.user.id.get() as i64 != room.owner_id { + respond_ephemeral_modal( + ctx, + modal, + "Seul l'owner du vocal temporaire peut soumettre ce formulaire.", ) .await; + return true; + } + + match action.as_str() { + "whitelist" => { + let raw = modal_value(modal, MEMBER_LIST_INPUT_ID).unwrap_or_default(); + room.whitelist_json = encode_member_list(&parse_user_ids_input(&raw)); + + if !persist_room_state(ctx, &pool, &mut room, Some("Whitelist mise a jour.")).await { + respond_ephemeral_modal(ctx, modal, "Impossible de mettre a jour la whitelist.") + .await; + return true; + } + + respond_ephemeral_modal(ctx, modal, "Whitelist mise a jour.").await; + return true; + } + "blacklist" => { + let raw = modal_value(modal, MEMBER_LIST_INPUT_ID).unwrap_or_default(); + room.blacklist_json = encode_member_list(&parse_user_ids_input(&raw)); + + if !persist_room_state(ctx, &pool, &mut room, Some("Blacklist mise a jour.")).await { + respond_ephemeral_modal(ctx, modal, "Impossible de mettre a jour la blacklist.") + .await; + return true; + } + + respond_ephemeral_modal(ctx, modal, "Blacklist mise a jour.").await; + return true; + } + "transfer" => { + let raw = modal_value(modal, TRANSFER_OWNER_INPUT_ID).unwrap_or_default(); + let Some(new_owner_id) = parse_user_ids_input(&raw).first().copied() else { + respond_ephemeral_modal(ctx, modal, "Utilisateur invalide.").await; + return true; + }; + + let guild_id = GuildId::new(room.guild_id as u64); + if guild_id + .member(&ctx.http, UserId::new(new_owner_id)) + .await + .is_err() + { + respond_ephemeral_modal(ctx, modal, "Ce membre n'est pas present sur le serveur.") + .await; + return true; + } + + room.owner_id = new_owner_id as i64; + if !persist_room_state(ctx, &pool, &mut room, Some("Owner transfere.")).await { + respond_ephemeral_modal(ctx, modal, "Impossible de transferer l'owner.").await; + return true; + } + + respond_ephemeral_modal( + ctx, + modal, + &format!("Owner transfere a <@{}>.", new_owner_id), + ) + .await; + return true; + } + "settings" => { + let guild_id = GuildId::new(room.guild_id as u64); + + let room_name_input = modal_value(modal, SETTINGS_NAME_INPUT_ID).unwrap_or_default(); + let room_name_input = normalize_room_name(&room_name_input); + if !room_name_input.is_empty() { + let Some(unique_name) = + unique_voice_name(ctx, guild_id, &room_name_input, Some(channel_id)).await + else { + respond_ephemeral_modal(ctx, modal, "Nom de salon invalide.").await; + return true; + }; + + if channel_id + .edit(&ctx.http, EditChannel::new().name(unique_name)) + .await + .is_err() + { + respond_ephemeral_modal(ctx, modal, "Impossible de renommer le vocal.").await; + return true; + } + + room.room_name = Some(room_name_input); + } + + let limit_input = modal_value(modal, SETTINGS_LIMIT_INPUT_ID).unwrap_or_default(); + if !limit_input.trim().is_empty() { + let Ok(parsed_limit) = limit_input.trim().parse::() else { + respond_ephemeral_modal( + ctx, + modal, + "La limite doit etre un nombre entre 0 et 99.", + ) + .await; + return true; + }; + + room.user_limit = parsed_limit.clamp(0, 99); + } + + if !persist_room_state(ctx, &pool, &mut room, Some("Parametres mis a jour.")).await { + respond_ephemeral_modal(ctx, modal, "Impossible d'appliquer les parametres.").await; + return true; + } + + respond_ephemeral_modal(ctx, modal, "Parametres appliques.").await; + return true; + } + _ => { + return false; + } } } -async fn delete_temp_channel(ctx: &Context, channel_id: ChannelId) { - if let Ok(channel) = channel_id.to_channel(&ctx.http).await { - if let Channel::Guild(guild_channel) = channel { - let _ = guild_channel.delete(&ctx.http).await; - } +pub async fn handle_modal_interaction(ctx: &Context, modal: &ModalInteraction) -> bool { + if modal.data.custom_id.starts_with(TEMPVOC_MENU) { + return handle_settings_modal_interaction(ctx, modal).await; } + + if modal + .data + .custom_id + .starts_with(&format!("tempvoc:{}:", TEMPVOC_MODAL_SCOPE)) + { + return handle_room_modal_interaction(ctx, modal).await; + } + + false } pub async fn handle_voice_state_update(ctx: &Context, old: Option<&VoiceState>, new: &VoiceState) { @@ -441,6 +1467,36 @@ pub async fn handle_voice_state_update(ctx: &Context, old: Option<&VoiceState>, } } +pub async fn cleanup_stale_rooms_on_ready(ctx: &Context) { + let Some(pool) = pool(ctx).await else { + return; + }; + + let bot_id = ctx.cache.current_user().id.get() as i64; + let Ok(rooms) = db::get_tempvoc_rooms_by_bot(&pool, bot_id).await else { + return; + }; + + for room in rooms { + let guild_id = GuildId::new(room.guild_id as u64); + let channel_id = ChannelId::new(room.channel_id as u64); + + if channel_id.to_channel(&ctx.http).await.is_err() { + let _ = db::delete_tempvoc_room(&pool, room.channel_id).await; + continue; + } + + let Some(_) = ctx.cache.guild(guild_id) else { + continue; + }; + + if cached_room_members(ctx, guild_id, channel_id).await == 0 { + delete_temp_channel(ctx, channel_id).await; + let _ = db::delete_tempvoc_room(&pool, room.channel_id).await; + } + } +} + pub struct TempvocCommand; pub static COMMAND_DESCRIPTOR: TempvocCommand = TempvocCommand; diff --git a/src/db.rs b/src/db.rs index 338aeaa..daa5f3b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -116,6 +116,15 @@ pub struct AutopublishChannel { pub updated_at: DateTime, } +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct PiconlyChannel { + pub bot_id: i64, + pub guild_id: i64, + pub channel_id: i64, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, sqlx::FromRow)] #[allow(dead_code)] pub struct TempvocSettings { @@ -127,6 +136,17 @@ pub struct TempvocSettings { pub updated_at: DateTime, } +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct OldMemberSettings { + pub bot_id: i64, + pub guild_id: i64, + pub role_id: Option, + pub delay_seconds: i64, + pub enabled: bool, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, sqlx::FromRow)] #[allow(dead_code)] pub struct TempvocRoom { @@ -135,9 +155,91 @@ pub struct TempvocRoom { pub guild_id: i64, pub channel_id: i64, pub owner_id: i64, + pub voice_mode: String, + pub whitelist_json: String, + pub blacklist_json: String, + pub allow_micro: bool, + pub allow_camera: bool, + pub allow_soundboard: bool, + pub user_limit: i32, + pub room_name: Option, + pub control_message_channel_id: Option, + pub control_message_id: Option, + pub updated_at: DateTime, pub created_at: DateTime, } +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct TempvocProfile { + pub bot_id: i64, + pub guild_id: i64, + pub user_id: i64, + pub voice_mode: String, + pub allow_micro: bool, + pub allow_camera: bool, + pub allow_soundboard: bool, + pub user_limit: i32, + pub room_name: Option, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct ModerationSettings { + pub bot_id: i64, + pub guild_id: i64, + pub use_timeout: bool, + pub clear_limit: i32, + pub mute_role_id: Option, + pub antispam_enabled: bool, + pub antispam_limit: i32, + pub antispam_window_seconds: i32, + pub antilink_enabled: bool, + pub antilink_mode: String, + pub antimassmention_enabled: bool, + pub antimassmention_limit: i32, + pub badwords_enabled: bool, + pub public_commands_enabled: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct ModerationChannelOverride { + pub bot_id: i64, + pub guild_id: i64, + pub channel_id: i64, + pub kind: String, + pub mode: String, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct StrikeRule { + pub bot_id: i64, + pub guild_id: i64, + pub trigger: String, + pub profile: String, + pub strike_count: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +#[allow(dead_code)] +pub struct PunishRule { + pub id: i64, + pub bot_id: i64, + pub guild_id: i64, + pub threshold: i32, + pub window_seconds: i64, + pub sanction: String, + pub sanction_seconds: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + pub async fn create_pool(database_url: &str) -> Result { PgPoolOptions::new() .max_connections(10) @@ -463,6 +565,58 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_old_member_settings ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + role_id BIGINT NULL, + delay_seconds BIGINT NOT NULL DEFAULT 2592000, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_old_member_settings + ADD COLUMN IF NOT EXISTS role_id BIGINT NULL; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_old_member_settings + ADD COLUMN IF NOT EXISTS delay_seconds BIGINT NOT NULL DEFAULT 2592000; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_old_member_settings + ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT FALSE; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_old_member_settings + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + "#, + ) + .execute(pool) + .await?; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS bot_log_channels ( @@ -738,6 +892,20 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_piconly_channels ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, channel_id) + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( r#" CREATE TABLE IF NOT EXISTS bot_tempvoc_settings ( @@ -762,6 +930,17 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { guild_id BIGINT NOT NULL, channel_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, + voice_mode TEXT NOT NULL DEFAULT 'open', + whitelist_json TEXT NOT NULL DEFAULT '[]', + blacklist_json TEXT NOT NULL DEFAULT '[]', + allow_micro BOOLEAN NOT NULL DEFAULT TRUE, + allow_camera BOOLEAN NOT NULL DEFAULT TRUE, + allow_soundboard BOOLEAN NOT NULL DEFAULT TRUE, + user_limit INTEGER NOT NULL DEFAULT 0, + room_name TEXT NULL, + control_message_channel_id BIGINT NULL, + control_message_id BIGINT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); "#, @@ -769,6 +948,125 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS voice_mode TEXT NOT NULL DEFAULT 'open'; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS whitelist_json TEXT NOT NULL DEFAULT '[]'; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS blacklist_json TEXT NOT NULL DEFAULT '[]'; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS allow_micro BOOLEAN NOT NULL DEFAULT TRUE; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS allow_camera BOOLEAN NOT NULL DEFAULT TRUE; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS allow_soundboard BOOLEAN NOT NULL DEFAULT TRUE; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS user_limit INTEGER NOT NULL DEFAULT 0; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS room_name TEXT NULL; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS control_message_channel_id BIGINT NULL; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS control_message_id BIGINT NULL; + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + ALTER TABLE bot_tempvoc_rooms + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_tempvoc_profiles ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + voice_mode TEXT NOT NULL DEFAULT 'open', + allow_micro BOOLEAN NOT NULL DEFAULT TRUE, + allow_camera BOOLEAN NOT NULL DEFAULT TRUE, + allow_soundboard BOOLEAN NOT NULL DEFAULT TRUE, + user_limit INTEGER NOT NULL DEFAULT 0, + room_name TEXT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, user_id) + ); + "#, + ) + .execute(pool) + .await?; + sqlx::query( r#" CREATE INDEX IF NOT EXISTS idx_bot_tempvoc_rooms_lookup @@ -778,6 +1076,159 @@ pub async fn init_schema(pool: &PgPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; + sqlx::query( + r#" + CREATE UNIQUE INDEX IF NOT EXISTS idx_bot_tempvoc_rooms_channel + ON bot_tempvoc_rooms (channel_id); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_moderation_settings ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + use_timeout BOOLEAN NOT NULL DEFAULT TRUE, + clear_limit INTEGER NOT NULL DEFAULT 100, + mute_role_id BIGINT NULL, + antispam_enabled BOOLEAN NOT NULL DEFAULT FALSE, + antispam_limit INTEGER NOT NULL DEFAULT 6, + antispam_window_seconds INTEGER NOT NULL DEFAULT 5, + antilink_enabled BOOLEAN NOT NULL DEFAULT FALSE, + antilink_mode TEXT NOT NULL DEFAULT 'invite', + antimassmention_enabled BOOLEAN NOT NULL DEFAULT FALSE, + antimassmention_limit INTEGER NOT NULL DEFAULT 5, + badwords_enabled BOOLEAN NOT NULL DEFAULT FALSE, + public_commands_enabled BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_badwords ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + word TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, word) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_moderation_channel_overrides ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + kind TEXT NOT NULL, + mode TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, channel_id, kind) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_noderank_roles ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, role_id) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_strike_rules ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + trigger TEXT NOT NULL, + profile TEXT NOT NULL, + strike_count INTEGER NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, trigger, profile) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_punish_rules ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + threshold INTEGER NOT NULL, + window_seconds BIGINT NOT NULL, + sanction TEXT NOT NULL, + sanction_seconds BIGINT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (bot_id, guild_id, threshold) + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_member_strike_events ( + id BIGSERIAL PRIMARY KEY, + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + trigger TEXT NOT NULL, + strike_count INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE INDEX IF NOT EXISTS idx_bot_member_strike_events_lookup + ON bot_member_strike_events (bot_id, guild_id, user_id, created_at DESC); + "#, + ) + .execute(pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS bot_member_punish_log ( + bot_id BIGINT NOT NULL, + guild_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + rule_id BIGINT NOT NULL, + last_triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, user_id, rule_id) + ); + "#, + ) + .execute(pool) + .await?; + Ok(()) } @@ -2639,6 +3090,146 @@ pub async fn get_autopublish_channels( Ok(channels) } +pub async fn add_piconly_channel( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_piconly_channels (bot_id, guild_id, channel_id) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, guild_id, channel_id) DO UPDATE SET updated_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn remove_piconly_channel( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + DELETE FROM bot_piconly_channels + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_piconly_channels( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result, sqlx::Error> { + let channels = sqlx::query_as::<_, PiconlyChannel>( + r#" + SELECT * FROM bot_piconly_channels + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY channel_id ASC; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_all(pool) + .await?; + + Ok(channels) +} + +pub async fn is_piconly_channel( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, +) -> Result { + let row = sqlx::query_as::<_, (bool,)>( + r#" + SELECT EXISTS( + SELECT 1 + FROM bot_piconly_channels + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 + ); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .fetch_one(pool) + .await?; + + Ok(row.0) +} + +// ========== ANCIEN SETTINGS FUNCTIONS ========== + +pub async fn get_or_create_old_member_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result { + let settings = sqlx::query_as::<_, OldMemberSettings>( + r#" + INSERT INTO bot_old_member_settings (bot_id, guild_id) + VALUES ($1, $2) + ON CONFLICT (bot_id, guild_id) DO UPDATE SET updated_at = NOW() + RETURNING *; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn update_old_member_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + role_id: Option, + delay_seconds: i64, + enabled: bool, +) -> Result { + let settings = sqlx::query_as::<_, OldMemberSettings>( + r#" + UPDATE bot_old_member_settings + SET role_id = $1, + delay_seconds = $2, + enabled = $3, + updated_at = NOW() + WHERE bot_id = $4 AND guild_id = $5 + RETURNING *; + "#, + ) + .bind(role_id) + .bind(delay_seconds.max(1)) + .bind(enabled) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + // ========== TEMPVOC FUNCTIONS ========== pub async fn get_or_create_tempvoc_settings( @@ -2689,17 +3280,111 @@ pub async fn update_tempvoc_settings( Ok(settings) } +pub async fn get_or_create_tempvoc_profile( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, +) -> Result { + let profile = sqlx::query_as::<_, TempvocProfile>( + r#" + INSERT INTO bot_tempvoc_profiles (bot_id, guild_id, user_id) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, guild_id, user_id) DO UPDATE SET updated_at = NOW() + RETURNING *; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .fetch_one(pool) + .await?; + + Ok(profile) +} + +pub async fn save_tempvoc_profile( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, + voice_mode: &str, + allow_micro: bool, + allow_camera: bool, + allow_soundboard: bool, + user_limit: i32, + room_name: Option<&str>, +) -> Result { + let profile = sqlx::query_as::<_, TempvocProfile>( + r#" + INSERT INTO bot_tempvoc_profiles ( + bot_id, + guild_id, + user_id, + voice_mode, + allow_micro, + allow_camera, + allow_soundboard, + user_limit, + room_name + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (bot_id, guild_id, user_id) + DO UPDATE SET + voice_mode = EXCLUDED.voice_mode, + allow_micro = EXCLUDED.allow_micro, + allow_camera = EXCLUDED.allow_camera, + allow_soundboard = EXCLUDED.allow_soundboard, + user_limit = EXCLUDED.user_limit, + room_name = EXCLUDED.room_name, + updated_at = NOW() + RETURNING *; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .bind(voice_mode) + .bind(allow_micro) + .bind(allow_camera) + .bind(allow_soundboard) + .bind(user_limit) + .bind(room_name) + .fetch_one(pool) + .await?; + + Ok(profile) +} + pub async fn create_tempvoc_room( pool: &PgPool, bot_id: i64, guild_id: i64, channel_id: i64, owner_id: i64, + voice_mode: &str, + allow_micro: bool, + allow_camera: bool, + allow_soundboard: bool, + user_limit: i32, + room_name: Option<&str>, ) -> Result { let room = sqlx::query_as::<_, TempvocRoom>( r#" - INSERT INTO bot_tempvoc_rooms (bot_id, guild_id, channel_id, owner_id) - VALUES ($1, $2, $3, $4) + INSERT INTO bot_tempvoc_rooms ( + bot_id, + guild_id, + channel_id, + owner_id, + voice_mode, + allow_micro, + allow_camera, + allow_soundboard, + user_limit, + room_name, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW()) RETURNING *; "#, ) @@ -2707,12 +3392,84 @@ pub async fn create_tempvoc_room( .bind(guild_id) .bind(channel_id) .bind(owner_id) + .bind(voice_mode) + .bind(allow_micro) + .bind(allow_camera) + .bind(allow_soundboard) + .bind(user_limit) + .bind(room_name) .fetch_one(pool) .await?; Ok(room) } +pub async fn set_tempvoc_room_control_message( + pool: &PgPool, + channel_id: i64, + control_message_channel_id: i64, + control_message_id: i64, +) -> Result { + let room = sqlx::query_as::<_, TempvocRoom>( + r#" + UPDATE bot_tempvoc_rooms + SET control_message_channel_id = $1, + control_message_id = $2, + updated_at = NOW() + WHERE channel_id = $3 + RETURNING *; + "#, + ) + .bind(control_message_channel_id) + .bind(control_message_id) + .bind(channel_id) + .fetch_one(pool) + .await?; + + Ok(room) +} + +pub async fn save_tempvoc_room_state( + pool: &PgPool, + room: &TempvocRoom, +) -> Result { + let updated = sqlx::query_as::<_, TempvocRoom>( + r#" + UPDATE bot_tempvoc_rooms + SET owner_id = $1, + voice_mode = $2, + whitelist_json = $3, + blacklist_json = $4, + allow_micro = $5, + allow_camera = $6, + allow_soundboard = $7, + user_limit = $8, + room_name = $9, + control_message_channel_id = $10, + control_message_id = $11, + updated_at = NOW() + WHERE channel_id = $12 + RETURNING *; + "#, + ) + .bind(room.owner_id) + .bind(&room.voice_mode) + .bind(&room.whitelist_json) + .bind(&room.blacklist_json) + .bind(room.allow_micro) + .bind(room.allow_camera) + .bind(room.allow_soundboard) + .bind(room.user_limit) + .bind(room.room_name.as_deref()) + .bind(room.control_message_channel_id) + .bind(room.control_message_id) + .bind(room.channel_id) + .fetch_one(pool) + .await?; + + Ok(updated) +} + pub async fn get_tempvoc_room_by_channel( pool: &PgPool, channel_id: i64, @@ -2729,6 +3486,22 @@ pub async fn get_tempvoc_room_by_channel( Ok(room) } +pub async fn get_tempvoc_rooms_by_bot( + pool: &PgPool, + bot_id: i64, +) -> Result, sqlx::Error> { + let rooms = sqlx::query_as::<_, TempvocRoom>( + r#" + SELECT * FROM bot_tempvoc_rooms WHERE bot_id = $1; + "#, + ) + .bind(bot_id) + .fetch_all(pool) + .await?; + + Ok(rooms) +} + pub async fn delete_tempvoc_room(pool: &PgPool, channel_id: i64) -> Result<(), sqlx::Error> { sqlx::query( r#" @@ -2741,3 +3514,881 @@ pub async fn delete_tempvoc_room(pool: &PgPool, channel_id: i64) -> Result<(), s Ok(()) } + +async fn ensure_moderation_settings_row( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_moderation_settings (bot_id, guild_id) + VALUES ($1, $2) + ON CONFLICT (bot_id, guild_id) DO NOTHING; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_or_create_moderation_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + SELECT * + FROM bot_moderation_settings + WHERE bot_id = $1 AND guild_id = $2 + LIMIT 1; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_use_timeout_for_mute( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET use_timeout = $1, updated_at = NOW() + WHERE bot_id = $2 AND guild_id = $3 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_clear_limit( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + clear_limit: i32, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET clear_limit = $1, updated_at = NOW() + WHERE bot_id = $2 AND guild_id = $3 + RETURNING *; + "#, + ) + .bind(clear_limit.max(1)) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_mute_role( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + mute_role_id: Option, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET mute_role_id = $1, updated_at = NOW() + WHERE bot_id = $2 AND guild_id = $3 + RETURNING *; + "#, + ) + .bind(mute_role_id) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_antispam_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, + limit: i32, + window_seconds: i32, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET antispam_enabled = $1, + antispam_limit = $2, + antispam_window_seconds = $3, + updated_at = NOW() + WHERE bot_id = $4 AND guild_id = $5 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(limit.max(1)) + .bind(window_seconds.max(1)) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_antilink_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, + mode: &str, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET antilink_enabled = $1, + antilink_mode = $2, + updated_at = NOW() + WHERE bot_id = $3 AND guild_id = $4 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(mode) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_antimassmention_settings( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, + limit: i32, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET antimassmention_enabled = $1, + antimassmention_limit = $2, + updated_at = NOW() + WHERE bot_id = $3 AND guild_id = $4 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(limit.max(1)) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_badwords_enabled( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET badwords_enabled = $1, + updated_at = NOW() + WHERE bot_id = $2 AND guild_id = $3 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn set_public_commands_enabled( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + enabled: bool, +) -> Result { + ensure_moderation_settings_row(pool, bot_id, guild_id).await?; + let settings = sqlx::query_as::<_, ModerationSettings>( + r#" + UPDATE bot_moderation_settings + SET public_commands_enabled = $1, + updated_at = NOW() + WHERE bot_id = $2 AND guild_id = $3 + RETURNING *; + "#, + ) + .bind(enabled) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await?; + + Ok(settings) +} + +pub async fn add_badword( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + word: &str, +) -> Result<(), sqlx::Error> { + let normalized = word.trim().to_lowercase(); + if normalized.is_empty() { + return Ok(()); + } + + sqlx::query( + r#" + INSERT INTO bot_badwords (bot_id, guild_id, word) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, guild_id, word) DO NOTHING; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(normalized) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn remove_badword( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + word: &str, +) -> Result { + let normalized = word.trim().to_lowercase(); + let res = sqlx::query( + r#" + DELETE FROM bot_badwords + WHERE bot_id = $1 AND guild_id = $2 AND word = $3; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(normalized) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn clear_badwords(pool: &PgPool, bot_id: i64, guild_id: i64) -> Result { + let res = sqlx::query( + r#" + DELETE FROM bot_badwords + WHERE bot_id = $1 AND guild_id = $2; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn list_badwords( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (String,)>( + r#" + SELECT word + FROM bot_badwords + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY word ASC; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(word,)| word).collect()) +} + +pub async fn set_moderation_channel_override( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, + kind: &str, + mode: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_moderation_channel_overrides (bot_id, guild_id, channel_id, kind, mode) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (bot_id, guild_id, channel_id, kind) + DO UPDATE SET mode = EXCLUDED.mode, updated_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .bind(kind) + .bind(mode) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn remove_moderation_channel_override( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, + kind: &str, +) -> Result { + let res = sqlx::query( + r#" + DELETE FROM bot_moderation_channel_overrides + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 AND kind = $4; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .bind(kind) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn clear_moderation_channel_overrides_by_kind( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + kind: &str, +) -> Result { + let res = sqlx::query( + r#" + DELETE FROM bot_moderation_channel_overrides + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(kind) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn get_moderation_channel_override( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + channel_id: i64, + kind: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, (String,)>( + r#" + SELECT mode + FROM bot_moderation_channel_overrides + WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3 AND kind = $4 + LIMIT 1; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(channel_id) + .bind(kind) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(mode,)| mode)) +} + +pub async fn add_noderank_role( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + role_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_noderank_roles (bot_id, guild_id, role_id) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, guild_id, role_id) + DO UPDATE SET updated_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(role_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn remove_noderank_role( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + role_id: i64, +) -> Result { + let res = sqlx::query( + r#" + DELETE FROM bot_noderank_roles + WHERE bot_id = $1 AND guild_id = $2 AND role_id = $3; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(role_id) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn list_noderank_roles( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, (i64,)>( + r#" + SELECT role_id + FROM bot_noderank_roles + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY role_id ASC; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(role_id,)| role_id).collect()) +} + +#[allow(dead_code)] +pub async fn is_noderank_role( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + role_id: i64, +) -> Result { + let row = sqlx::query_as::<_, (i64,)>( + r#" + SELECT 1 + FROM bot_noderank_roles + WHERE bot_id = $1 AND guild_id = $2 AND role_id = $3 + LIMIT 1; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(role_id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn ensure_default_strike_rules( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result<(), sqlx::Error> { + let defaults = [ + ("spam", "new", 2_i32), + ("spam", "old", 1_i32), + ("link", "new", 2_i32), + ("link", "old", 1_i32), + ("massmention", "new", 3_i32), + ("massmention", "old", 2_i32), + ("badword", "new", 2_i32), + ("badword", "old", 1_i32), + ]; + + for (trigger, profile, strike_count) in defaults { + let _ = sqlx::query( + r#" + INSERT INTO bot_strike_rules (bot_id, guild_id, trigger, profile, strike_count) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (bot_id, guild_id, trigger, profile) DO NOTHING; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(trigger) + .bind(profile) + .bind(strike_count) + .execute(pool) + .await; + } + + Ok(()) +} + +pub async fn upsert_strike_rule( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + trigger: &str, + profile: &str, + strike_count: i32, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_strike_rules (bot_id, guild_id, trigger, profile, strike_count) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (bot_id, guild_id, trigger, profile) + DO UPDATE SET strike_count = EXCLUDED.strike_count, updated_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(trigger) + .bind(profile) + .bind(strike_count.max(0)) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_strike_rules( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result, sqlx::Error> { + ensure_default_strike_rules(pool, bot_id, guild_id).await?; + let rows = sqlx::query_as::<_, StrikeRule>( + r#" + SELECT * + FROM bot_strike_rules + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY trigger ASC, profile ASC; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub async fn get_strike_rule( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + trigger: &str, + profile: &str, +) -> Result, sqlx::Error> { + ensure_default_strike_rules(pool, bot_id, guild_id).await?; + let row = sqlx::query_as::<_, (i32,)>( + r#" + SELECT strike_count + FROM bot_strike_rules + WHERE bot_id = $1 AND guild_id = $2 AND trigger = $3 AND profile = $4 + LIMIT 1; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(trigger) + .bind(profile) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(value,)| value)) +} + +pub async fn setup_default_punish_rules( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + DELETE FROM bot_punish_rules + WHERE bot_id = $1 AND guild_id = $2; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .execute(pool) + .await?; + + let defaults = [ + (5_i32, 3_600_i64, "warn", None), + (8_i32, 21_600_i64, "mute", Some(1_800_i64)), + (12_i32, 86_400_i64, "ban", None), + ]; + + for (threshold, window_seconds, sanction, sanction_seconds) in defaults { + sqlx::query( + r#" + INSERT INTO bot_punish_rules (bot_id, guild_id, threshold, window_seconds, sanction, sanction_seconds) + VALUES ($1, $2, $3, $4, $5, $6); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(threshold) + .bind(window_seconds) + .bind(sanction) + .bind(sanction_seconds) + .execute(pool) + .await?; + } + + Ok(()) +} + +pub async fn ensure_default_punish_rules( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result<(), sqlx::Error> { + let count = sqlx::query_as::<_, (i64,)>( + r#" + SELECT COUNT(*) + FROM bot_punish_rules + WHERE bot_id = $1 AND guild_id = $2; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_one(pool) + .await? + .0; + + if count == 0 { + setup_default_punish_rules(pool, bot_id, guild_id).await?; + } + + Ok(()) +} + +pub async fn list_punish_rules( + pool: &PgPool, + bot_id: i64, + guild_id: i64, +) -> Result, sqlx::Error> { + ensure_default_punish_rules(pool, bot_id, guild_id).await?; + let rows = sqlx::query_as::<_, PunishRule>( + r#" + SELECT * + FROM bot_punish_rules + WHERE bot_id = $1 AND guild_id = $2 + ORDER BY threshold ASC, id ASC; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub async fn upsert_punish_rule( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + threshold: i32, + window_seconds: i64, + sanction: &str, + sanction_seconds: Option, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_punish_rules (bot_id, guild_id, threshold, window_seconds, sanction, sanction_seconds) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (bot_id, guild_id, threshold) + DO UPDATE SET + window_seconds = EXCLUDED.window_seconds, + sanction = EXCLUDED.sanction, + sanction_seconds = EXCLUDED.sanction_seconds, + updated_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(threshold.max(1)) + .bind(window_seconds.max(1)) + .bind(sanction) + .bind(sanction_seconds) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_punish_rule_by_id( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + rule_id: i64, +) -> Result { + let res = sqlx::query( + r#" + DELETE FROM bot_punish_rules + WHERE bot_id = $1 AND guild_id = $2 AND id = $3; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(rule_id) + .execute(pool) + .await?; + + Ok(res.rows_affected()) +} + +pub async fn add_member_strike_event( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, + trigger: &str, + strike_count: i32, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_member_strike_events (bot_id, guild_id, user_id, trigger, strike_count) + VALUES ($1, $2, $3, $4, $5); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .bind(trigger) + .bind(strike_count.max(0)) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn count_member_strikes_in_window( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, + window_seconds: i64, +) -> Result { + let row = sqlx::query_as::<_, (i64,)>( + r#" + SELECT COALESCE(SUM(strike_count), 0) + FROM bot_member_strike_events + WHERE bot_id = $1 + AND guild_id = $2 + AND user_id = $3 + AND created_at >= (NOW() - ($4::BIGINT * INTERVAL '1 second')); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .bind(window_seconds.max(1)) + .fetch_one(pool) + .await?; + + Ok(row.0) +} + +pub async fn get_last_punish_triggered_at( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, + rule_id: i64, +) -> Result>, sqlx::Error> { + let row = sqlx::query_as::<_, (DateTime,)>( + r#" + SELECT last_triggered_at + FROM bot_member_punish_log + WHERE bot_id = $1 AND guild_id = $2 AND user_id = $3 AND rule_id = $4 + LIMIT 1; + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .bind(rule_id) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(at,)| at)) +} + +pub async fn upsert_last_punish_triggered_at( + pool: &PgPool, + bot_id: i64, + guild_id: i64, + user_id: i64, + rule_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO bot_member_punish_log (bot_id, guild_id, user_id, rule_id, last_triggered_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (bot_id, guild_id, user_id, rule_id) + DO UPDATE SET last_triggered_at = NOW(); + "#, + ) + .bind(bot_id) + .bind(guild_id) + .bind(user_id) + .bind(rule_id) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/events/guild_member_event.rs b/src/events/guild_member_event.rs index 9199a0f..bccaf13 100644 --- a/src/events/guild_member_event.rs +++ b/src/events/guild_member_event.rs @@ -1,10 +1,11 @@ use serenity::model::prelude::*; use serenity::prelude::*; -use crate::commands::logs_service; +use crate::commands::{ancien, logs_service}; pub async fn handle_member_addition(ctx: &Context, new_member: &Member) { logs_service::on_member_join(ctx, new_member.guild_id, &new_member.user).await; + ancien::maybe_assign_ancien_role(ctx, new_member.guild_id, new_member.user.id).await; } pub async fn handle_member_removal(ctx: &Context, guild_id: GuildId, user: &User) { @@ -35,6 +36,8 @@ pub async fn handle_member_update( new_member.premium_since, ) .await; + + ancien::maybe_assign_ancien_role(ctx, new_member.guild_id, new_member.user.id).await; return; } diff --git a/src/events/interaction_create_event.rs b/src/events/interaction_create_event.rs index 451a7ba..6552077 100644 --- a/src/events/interaction_create_event.rs +++ b/src/events/interaction_create_event.rs @@ -2,8 +2,8 @@ use serenity::model::prelude::*; use serenity::prelude::*; use crate::commands::{ - advanced_tools, autoconfiglog, boostembed, help, helpsetting, mp, perms_service, rolemenu, - suggestion, tempvoc, ticket, viewlogs, + advanced_tools, ancien, autoconfiglog, boostembed, help, helpsetting, mp, perms_service, + rolemenu, suggestion, tempvoc, ticket, viewlogs, }; pub async fn handle_interaction_create(ctx: &Context, interaction: &Interaction) { @@ -14,6 +14,10 @@ pub async fn handle_interaction_create(ctx: &Context, interaction: &Interaction) } if let Interaction::Component(component) = interaction { + if ancien::handle_component_interaction(ctx, component).await { + return; + } + if autoconfiglog::handle_component_interaction(ctx, component).await { return; } @@ -63,6 +67,10 @@ pub async fn handle_interaction_create(ctx: &Context, interaction: &Interaction) } if let Interaction::Modal(modal) = interaction { + if ancien::handle_modal_interaction(ctx, modal).await { + return; + } + if ticket::handle_modal_interaction(ctx, modal).await { return; } diff --git a/src/events/message_event.rs b/src/events/message_event.rs index 1746f75..ae0fcb8 100644 --- a/src/events/message_event.rs +++ b/src/events/message_event.rs @@ -6,19 +6,22 @@ use std::sync::{Mutex, OnceLock}; use crate::commands::moderation_tools; use crate::commands::remove_activity; use crate::commands::{ - addrole, alias, autobackup, autoconfiglog, autopublish, autoreact, backup, ban, banlist, - banner, bl, blinfo, boostembed, boosters, boostlog, bringall, button, calc, change, changeall, - channel, choose, claim, cleanup, clear_all_sanctions, clear_bl, clear_messages, clear_owners, - clear_perms, clear_sanctions, close, cmute, compet, create, del, del_sanction, delrole, derank, - discussion, dnd, embed, emoji, end, giveaway, help, helpsetting, hide, hideall, idle, - invisible, invite, join, kick, leave, leave_settings, listen, loading, lock, lockall, - mainprefix, massiverole, member, messagelog, modlog, mp, mute, mutelist, newsticker, nolog, - online, owner, perms, pic, ping, playto, prefix, raidlog, rename, renew, reroll, role, rolelog, + addrole, alias, ancien, antilink, antimassmention, antiraideautoconfig, antispam, autobackup, + autoconfiglog, autopublish, autoreact, backup, badwords, ban, banlist, banner, bl, blinfo, + boostembed, boosters, boostlog, bringall, button, calc, change, changeall, channel, choose, + claim, cleanup, clear_all_sanctions, clear_badwords, clear_bl, clear_limit, clear_messages, + clear_owners, clear_perms, clear_sanctions, close, cmute, compet, create, del, del_sanction, + delrole, derank, discussion, dnd, embed, emoji, end, giveaway, help, helpsetting, hide, + hideall, idle, invisible, invite, join, kick, leave, leave_settings, link, listen, loading, + lock, lockall, mainprefix, massiverole, member, messagelog, modlog, mp, mute, mutelist, + muterole, newsticker, noderank, nolog, online, owner, perms, pic, piconly, ping, playto, + prefix, public, punish, raidlog, rename, renew, reroll, resetantiraide, role, rolelog, rolemembers, rolemenu, sanctions, say, server, serverinfo, set, set_boostembed, set_modlogs, - shadowbot, showpics, slowmode, snipe, stream, suggestion, sync, tempban, tempcmute, tempmute, - temprole, tempvoc, tempvoc_cmd, theme, ticket, ticket_member, tickets, unban, unbanall, unbl, - uncmute, unhide, unhideall, unlock, unlockall, unmassiverole, unmute, unmuteall, unowner, - untemprole, user, viewlogs, vocinfo, voicekick, voicelog, voicemove, warn, watch, + set_muterole, shadowbot, showpics, slowmode, snipe, spam, stream, strikes, suggestion, sync, + tempban, tempcmute, tempmute, temprole, tempvoc, tempvoc_cmd, theme, ticket, ticket_member, + tickets, timeout, unban, unbanall, unbl, uncmute, unhide, unhideall, unlock, unlockall, + unmassiverole, unmute, unmuteall, unowner, untemprole, user, viewlogs, vocinfo, voicekick, + voicelog, voicemove, warn, watch, }; use crate::commands::{alladmins, allbots, allperms, botadmins}; use crate::db::{DbPoolKey, upsert_message_observed}; @@ -83,12 +86,24 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { return; } + if let Some(guild_id) = msg.guild_id { + ancien::maybe_assign_ancien_role(ctx, guild_id, msg.author.id).await; + } + + let content = msg.content.trim(); + let prefix_value = permissions::resolve_prefix(ctx, msg.guild_id).await; + if piconly::enforce_piconly_message(ctx, msg, content, &prefix_value).await { + return; + } + crate::commands::advanced_tools::apply_autoreacts(ctx, msg).await; crate::commands::advanced_tools::maybe_run_maintenance(ctx, msg.guild_id).await; moderation_tools::maybe_run_maintenance(ctx, msg.guild_id).await; - let content = msg.content.trim(); - let prefix_value = permissions::resolve_prefix(ctx, msg.guild_id).await; + if crate::commands::automod_service::enforce_automod_message(ctx, msg).await { + return; + } + if !content.starts_with(&prefix_value) { return; } @@ -151,9 +166,15 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { } } + let required = permissions::command_required_permission(ctx, &command_key).await; + if !crate::commands::automod_service::public_command_allowed(ctx, msg, &command_key, required) + .await + { + return; + } + let can_use = permissions::can_use_command(ctx, msg, &command_key).await; if !can_use { - let required = permissions::command_required_permission(ctx, &command_key).await; permissions::deny_permission(ctx, msg, &command_key, required).await; return; } @@ -190,6 +211,7 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { { showpics::handle_show_pics(ctx, msg, &args[1..]).await } + "piconly" => piconly::handle_piconly(ctx, msg, &args).await, "suggestion" => suggestion::handle_suggestion(ctx, msg, &args).await, "autopublish" => autopublish::handle_autopublish(ctx, msg, &args).await, "tempvoc" @@ -202,6 +224,21 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { } "tempvoc" => tempvoc::handle_tempvoc(ctx, msg, &args).await, "ping" => ping::handle_ping(ctx, msg, &args).await, + "timeout" => timeout::handle_timeout_toggle(ctx, msg, &args).await, + "muterole" => muterole::handle_muterole(ctx, msg, &args).await, + "antispam" => antispam::handle_antispam(ctx, msg, &args).await, + "antiraideautoconfig" => { + antiraideautoconfig::handle_antiraideautoconfig(ctx, msg, &args).await + } + "antilink" => antilink::handle_antilink(ctx, msg, &args).await, + "antimassmention" => antimassmention::handle_antimassmention(ctx, msg, &args).await, + "badwords" => badwords::handle_badwords(ctx, msg, &args).await, + "spam" => spam::handle_spam_override(ctx, msg, &args).await, + "link" => link::handle_link_override(ctx, msg, &args).await, + "strikes" => strikes::handle_strikes(ctx, msg, &args).await, + "punish" => punish::handle_punish(ctx, msg, &args).await, + "public" => public::handle_public(ctx, msg, &args).await, + "resetantiraide" => resetantiraide::handle_resetantiraide(ctx, msg, &args).await, "allbots" => allbots::handle_allbots(ctx, msg, &args).await, "alladmins" => alladmins::handle_alladmins(ctx, msg, &args).await, "botadmins" => botadmins::handle_botadmins(ctx, msg, &args).await, @@ -211,6 +248,7 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { "vocinfo" => vocinfo::handle_vocinfo(ctx, msg, &args).await, "role" => role::handle_role(ctx, msg, &args).await, "rolemenu" => rolemenu::handle_rolemenu(ctx, msg, &args).await, + "ancien" => ancien::handle_ancien(ctx, msg, &args).await, "channel" => channel::handle_channel(ctx, msg, &args).await, "user" => user::handle_user(ctx, msg, &args).await, "member" => member::handle_member(ctx, msg, &args).await, @@ -359,6 +397,7 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { derank::handle_derank(ctx, msg, &args).await; crate::commands::logs_service::log_moderation_command(ctx, msg, "derank", &args).await; } + "noderank" => noderank::handle_noderank(ctx, msg, &args).await, "temprole" => temprole::handle_temprole(ctx, msg, &args).await, "untemprole" => untemprole::handle_untemprole(ctx, msg, &args).await, "sync" => sync::handle_sync(ctx, msg, &args).await, @@ -366,6 +405,14 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { "autoreact" => autoreact::handle_autoreact(ctx, msg, &args).await, "calc" => calc::handle_calc(ctx, msg, &args).await, "shadowbot" => shadowbot::handle_shadowbot(ctx, msg, &args).await, + "set" + if args + .first() + .map(|s| s.eq_ignore_ascii_case("muterole")) + .unwrap_or(false) => + { + set_muterole::handle_set_muterole(ctx, msg, &args).await + } "set" if args .first() @@ -453,6 +500,22 @@ pub async fn handle_message(ctx: &Context, msg: &Message) { { clear_perms::handle_clear_perms(ctx, msg).await } + "clear" + if args + .first() + .map(|s| s.eq_ignore_ascii_case("limit")) + .unwrap_or(false) => + { + clear_limit::handle_clear_limit(ctx, msg, &args).await + } + "clear" + if args + .first() + .map(|s| s.eq_ignore_ascii_case("badwords")) + .unwrap_or(false) => + { + clear_badwords::handle_clear_badwords(ctx, msg, &args).await + } "clear" if args .first() diff --git a/src/events/ready_event.rs b/src/events/ready_event.rs index bbe8d4a..cff0097 100644 --- a/src/events/ready_event.rs +++ b/src/events/ready_event.rs @@ -1,11 +1,12 @@ use serenity::model::prelude::*; use serenity::prelude::*; -use crate::commands::{admin_service, botconfig_service, help}; +use crate::commands::{admin_service, botconfig_service, help, tempvoc}; pub async fn handle_ready(ctx: &Context, ready: &Ready) { botconfig_service::restore_presence_from_db(ctx).await; help::register_slash_help(ctx).await; + tempvoc::cleanup_stale_rooms_on_ready(ctx).await; for guild_id in ctx.cache.guilds() { admin_service::enforce_blacklist_on_guild(ctx, guild_id).await; diff --git a/src/permissions.rs b/src/permissions.rs index 4de316e..c4c3db9 100644 --- a/src/permissions.rs +++ b/src/permissions.rs @@ -82,6 +82,18 @@ pub fn command_key(command: &str, args: &[&str]) -> String { .unwrap_or(false) { "clear_bl".to_string() + } else if args + .first() + .map(|s| s.eq_ignore_ascii_case("limit")) + .unwrap_or(false) + { + "clear_limit".to_string() + } else if args + .first() + .map(|s| s.eq_ignore_ascii_case("badwords")) + .unwrap_or(false) + { + "clear_badwords".to_string() } else if args .first() .map(|s| s.eq_ignore_ascii_case("perms")) @@ -132,6 +144,12 @@ pub fn command_key(command: &str, args: &[&str]) -> String { .unwrap_or(false) { "set_perm".to_string() + } else if args + .first() + .map(|s| s.eq_ignore_ascii_case("muterole")) + .unwrap_or(false) + { + "set_muterole".to_string() } else if args .first() .map(|s| s.eq_ignore_ascii_case("modlogs")) @@ -233,6 +251,7 @@ pub fn all_command_keys() -> Vec { "boosters", "rolemembers", "rolemenu", + "ancien", "serverinfo", "vocinfo", "role", @@ -252,6 +271,23 @@ pub fn all_command_keys() -> Vec { "clear_sanctions", "clear_all_sanctions", "clear_messages", + "clear_limit", + "clear_badwords", + "timeout", + "muterole", + "set_muterole", + "antispam", + "antiraideautoconfig", + "antilink", + "antimassmention", + "badwords", + "spam", + "link", + "strikes", + "punish", + "noderank", + "public", + "resetantiraide", "warn", "mute", "tempmute", @@ -290,6 +326,7 @@ pub fn all_command_keys() -> Vec { "ticket_close", "tickets", "show_pics", + "piconly", "suggestion_create", "suggestion_settings", "autopublish", @@ -374,14 +411,14 @@ pub fn all_command_keys() -> Vec { pub fn default_permission(command_key: &str) -> u8 { match command_key { - "ticket_settings" | "suggestion_settings" | "autopublish" | "tempvoc" => 8, + "ticket_settings" | "suggestion_settings" | "autopublish" | "piconly" | "tempvoc" => 8, "claim" | "rename" | "ticket_add" | "ticket_remove" | "ticket_close" | "tickets" => 2, "show_pics" | "suggestion_create" | "tempvoc_cmd" => 0, "owner" | "unowner" | "clear_owners" => 9, "bl" | "unbl" | "blinfo" | "clear_bl" => 9, "change" | "changeall" | "change_reset" | "mainprefix" | "set_perm" | "del_perm" | "clear_perms" => 9, - "set_modlogs" | "set_boostembed" => 8, + "set_modlogs" | "set_boostembed" | "set_muterole" => 8, "prefix" | "perms" | "allperms" => 8, "help" | "server_list" => 0, "helpsetting" | "alias" | "leave" => 9, @@ -428,6 +465,22 @@ pub fn default_permission(command_key: &str) -> u8 { | "clear_sanctions" | "clear_all_sanctions" | "clear_messages" + | "clear_limit" + | "clear_badwords" + | "timeout" + | "muterole" + | "antispam" + | "antiraideautoconfig" + | "antilink" + | "antimassmention" + | "badwords" + | "spam" + | "link" + | "strikes" + | "punish" + | "noderank" + | "public" + | "resetantiraide" | "warn" | "mute" | "tempmute" @@ -455,6 +508,7 @@ pub fn default_permission(command_key: &str) -> u8 { | "delrole" | "derank" | "rolemenu" + | "ancien" | "modlog" | "messagelog" | "voicelog"