mirror of
https://github.com/arthur-pbty/shadowbot.git
synced 2026-06-11 03:14:29 +02:00
e0f40e9190
- Implemented `resetantiraide` command to reset anti-raid protections to default settings. - Added `set_muterole` command to define the mute role when timeout mode is disabled. - Created `spam` command to manage spam moderation channel overrides (allow, deny, reset). - Developed `strikes` command to display and modify strike rules for various triggers. - Introduced `timeout` command to toggle the use of Discord timeout for mutes. feat(outils): add piconly command to manage photo-only channels - Implemented `piconly` command to define or remove channels where only photos can be sent. - Added functionality to enforce photo-only rules in designated channels. feat(roles): add ancien and noderank commands for role management - Created `ancien` command to set up a role for members after a specified delay. - Implemented `noderank` command to manage protected roles that are not removed by derank actions.
603 lines
16 KiB
Rust
603 lines
16 KiB
Rust
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<Mutex<HashMap<(u64, u64, u64), VecDeque<Instant>>>> = OnceLock::new();
|
|
|
|
pub async fn pool(ctx: &Context) -> Option<sqlx::PgPool> {
|
|
let data = ctx.data.read().await;
|
|
data.get::<DbPoolKey>().cloned()
|
|
}
|
|
|
|
pub fn parse_on_off(input: &str) -> Option<bool> {
|
|
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<i64> {
|
|
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::<i64>().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::<i32>().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
|
|
}
|