netoyage et optimisation des commande pour que elle soit trier correctement

This commit is contained in:
Puechberty Arthur
2026-04-10 17:57:12 +02:00
parent 9a47588cdf
commit 00ae9cda11
72 changed files with 1788 additions and 1497 deletions
+53
View File
@@ -0,0 +1,53 @@
use serenity::builder::CreateEmbed;
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::commands::common::send_embed;
use crate::permissions::is_owner_user;
pub fn parse_user_id(input: &str) -> Option<UserId> {
let cleaned = input
.trim()
.trim_start_matches('<')
.trim_end_matches('>')
.trim_start_matches('@')
.trim_start_matches('!');
cleaned.parse::<u64>().ok().map(UserId::new)
}
pub async fn app_owner_id(ctx: &Context) -> Option<UserId> {
let info = ctx.http.get_current_application_info().await.ok()?;
info.owner.map(|u| u.id)
}
pub async fn ensure_owner(ctx: &Context, msg: &Message) -> Result<(), ()> {
if is_owner_user(ctx, msg.author.id).await {
Ok(())
} else {
let embed = CreateEmbed::new()
.title("Accès refusé")
.description("Cette commande est réservée aux owners du bot.")
.color(0xED4245);
send_embed(ctx, msg, embed).await;
Err(())
}
}
pub async fn ban_user_everywhere(ctx: &Context, user_id: UserId, reason: &str) -> (usize, usize) {
let guilds = ctx.cache.guilds();
let mut ok = 0usize;
let mut ko = 0usize;
for guild_id in guilds {
match guild_id
.ban_with_reason(&ctx.http, user_id, 0, reason)
.await
{
Ok(_) => ok += 1,
Err(_) => ko += 1,
}
}
(ok, ko)
}
+56
View File
@@ -0,0 +1,56 @@
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::db::{DbPoolKey, is_blacklisted, list_blacklisted_ids};
pub async fn enforce_blacklist_on_message(ctx: &Context, msg: &Message) -> bool {
if msg.author.bot {
return false;
}
let bot_id = ctx.cache.current_user().id;
let pool = {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
};
let Some(pool) = pool else {
return false;
};
let blacklisted = is_blacklisted(&pool, bot_id, msg.author.id)
.await
.unwrap_or(false);
if !blacklisted {
return false;
}
if let Some(guild_id) = msg.guild_id {
let _ = guild_id
.ban_with_reason(&ctx.http, msg.author.id, 0, "Blacklist globale du bot")
.await;
}
true
}
pub async fn enforce_blacklist_on_guild(ctx: &Context, guild_id: GuildId) {
let bot_id = ctx.cache.current_user().id;
let pool = {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
};
let Some(pool) = pool else {
return;
};
let users = list_blacklisted_ids(&pool, bot_id)
.await
.unwrap_or_default();
for uid in users {
let _ = guild_id
.ban_with_reason(&ctx.http, uid, 0, "Blacklist globale du bot")
.await;
}
}
File diff suppressed because it is too large Load Diff
+602
View File
@@ -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<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
}
+18
View File
@@ -0,0 +1,18 @@
pub fn parse_color(value: &str) -> Option<u32> {
let v = value.trim().to_lowercase();
match v.as_str() {
"red" | "rouge" => Some(0xED4245),
"green" | "vert" => Some(0x57F287),
"blue" | "bleu" => Some(0x5865F2),
"yellow" | "jaune" => Some(0xFEE75C),
"orange" => Some(0xFAA61A),
"purple" | "violet" => Some(0x9B59B6),
"pink" | "rose" => Some(0xEB459E),
"white" | "blanc" => Some(0xFFFFFF),
"black" | "noir" => Some(0x000000),
_ => {
let hex = v.trim_start_matches('#').trim_start_matches("0x");
u32::from_str_radix(hex, 16).ok()
}
}
}
+46
View File
@@ -0,0 +1,46 @@
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::activity::{RotatingActivityKind, parse_status, start_rotation};
use crate::db::DbPoolKey;
pub async fn restore_presence_from_db(ctx: &Context) {
let bot_id = ctx.cache.current_user().id;
let pool = {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
};
let Some(pool) = pool else {
return;
};
let status = match crate::db::get_bot_status(&pool, bot_id).await {
Ok(Some(saved)) => parse_status(&saved),
_ => OnlineStatus::Online,
};
ctx.set_presence(None, status);
let activity_row = crate::db::get_bot_activity(&pool, bot_id)
.await
.ok()
.flatten();
if let Some((kind_raw, messages_raw)) = activity_row {
let Some(kind) = RotatingActivityKind::from_db(&kind_raw) else {
return;
};
let messages: Vec<String> = messages_raw
.split('\n')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
if !messages.is_empty() {
start_rotation(ctx, kind, messages, status).await;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
#[derive(Clone, Copy)]
pub struct CommandMetadata {
pub name: &'static str,
pub category: &'static str,
pub allow_in_dm: bool,
pub default_permission: u8,
pub params: &'static str,
pub description: &'static str,
pub examples: &'static [&'static str],
pub default_aliases: &'static [&'static str],
}
pub trait CommandSpec: Send + Sync {
fn metadata(&self) -> CommandMetadata;
}
+141
View File
@@ -0,0 +1,141 @@
use serenity::builder::{CreateEmbed, CreateMessage};
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::db::{DbPoolKey, get_bot_theme};
pub fn parse_limit(args: &[&str], default: usize, max: usize) -> usize {
args.iter()
.find_map(|arg| arg.parse::<usize>().ok())
.map(|value| value.clamp(1, max))
.unwrap_or(default)
}
pub fn has_flag(args: &[&str], names: &[&str]) -> bool {
args.iter()
.any(|arg| names.iter().any(|name| arg.eq_ignore_ascii_case(name)))
}
pub fn truncate_text(input: &str, max_len: usize) -> String {
if input.chars().count() <= max_len {
return input.to_string();
}
let mut out = input
.chars()
.take(max_len.saturating_sub(1))
.collect::<String>();
out.push('…');
out
}
pub fn add_list_fields(mut embed: CreateEmbed, lines: &[String], base_name: &str) -> CreateEmbed {
if lines.is_empty() {
return embed.field(base_name, "Aucun résultat.", false);
}
let max_fields = 3;
let chunk_size = 12;
for (index, chunk) in lines.chunks(chunk_size).take(max_fields).enumerate() {
let field_name = if index == 0 {
base_name.to_string()
} else {
format!("{} (suite {})", base_name, index + 1)
};
let value = truncate_text(&chunk.join("\n"), 1024);
embed = embed.field(field_name, value, false);
}
let shown = (chunk_size * max_fields).min(lines.len());
if lines.len() > shown {
embed = embed.field(
"Affichage",
format!("{} éléments affichés sur {}.", shown, lines.len()),
false,
);
}
embed
}
pub fn mention_user(user_id: UserId) -> String {
format!("<@{}>", user_id.get())
}
pub fn discord_ts(timestamp: Timestamp, style: &str) -> String {
format!("<t:{}:{}>", timestamp.unix_timestamp(), style)
}
pub async fn send_embed(ctx: &Context, msg: &Message, embed: CreateEmbed) {
let color = theme_color(ctx).await;
let embed = embed.color(color);
let _ = msg
.channel_id
.send_message(&ctx.http, CreateMessage::new().embed(embed))
.await;
}
pub async fn theme_color(ctx: &Context) -> u32 {
let bot_id = ctx.cache.current_user().id;
let pool = {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
};
if let Some(pool) = pool {
if let Ok(Some(color)) = get_bot_theme(&pool, bot_id).await {
return color;
}
}
0xFF0000
}
pub fn parse_role(guild: &PartialGuild, input: &str) -> Option<Role> {
// Essayer de parser comme mention <@&id>
if let Ok(id) = input
.trim_start_matches("<@&")
.trim_end_matches('>')
.parse::<u64>()
{
if let Some(role) = guild.roles.get(&RoleId::new(id)) {
return Some(role.clone());
}
}
// Essayer de parser comme ID brut
if let Ok(id) = input.parse::<u64>() {
if let Some(role) = guild.roles.get(&RoleId::new(id)) {
return Some(role.clone());
}
}
// Chercher par nom (case-insensitive)
let search = input.to_lowercase();
guild
.roles
.values()
.find(|r| r.name.to_lowercase().contains(&search))
.cloned()
}
pub fn parse_channel_id(input: &str) -> Option<ChannelId> {
// Essayer de parser comme mention <#id>
if let Ok(id) = input
.trim_start_matches("<#")
.trim_end_matches('>')
.parse::<u64>()
{
return Some(ChannelId::new(id));
}
// Essayer de parser comme ID brut
if let Ok(id) = input.parse::<u64>() {
return Some(ChannelId::new(id));
}
None
}
+125
View File
@@ -0,0 +1,125 @@
use serenity::async_trait;
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::events::{
channel_event, guild_create_event, guild_member_event, interaction_create_event,
message_delete_event, message_event, message_update_event, ready_event, role_event,
voice_state_update_event,
};
pub struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
ready_event::handle_ready(&ctx, &ready).await;
}
async fn message(&self, ctx: Context, msg: Message) {
message_event::handle_message(&ctx, &msg).await;
}
async fn guild_create(&self, ctx: Context, guild: Guild, _is_new: Option<bool>) {
guild_create_event::handle_guild_create(&ctx, &guild).await;
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
interaction_create_event::handle_interaction_create(&ctx, &interaction).await;
}
async fn message_delete(
&self,
ctx: Context,
channel_id: ChannelId,
deleted_message_id: MessageId,
guild_id: Option<GuildId>,
) {
message_delete_event::handle_message_delete(&ctx, channel_id, deleted_message_id, guild_id)
.await;
}
async fn message_update(
&self,
ctx: Context,
old_if_available: Option<Message>,
new: Option<Message>,
event: MessageUpdateEvent,
) {
message_update_event::handle_message_update(&ctx, old_if_available, new, &event).await;
}
async fn voice_state_update(&self, ctx: Context, old: Option<VoiceState>, new: VoiceState) {
voice_state_update_event::handle_voice_state_update(&ctx, old, &new).await;
}
async fn guild_member_addition(&self, ctx: Context, new_member: Member) {
guild_member_event::handle_member_addition(&ctx, &new_member).await;
}
async fn guild_member_removal(
&self,
ctx: Context,
guild_id: GuildId,
user: User,
_member_data_if_available: Option<Member>,
) {
guild_member_event::handle_member_removal(&ctx, guild_id, &user).await;
}
async fn guild_member_update(
&self,
ctx: Context,
old_if_available: Option<Member>,
new: Option<Member>,
event: GuildMemberUpdateEvent,
) {
guild_member_event::handle_member_update(&ctx, old_if_available, new, &event).await;
}
async fn guild_role_create(&self, ctx: Context, new: Role) {
role_event::handle_role_create(&ctx, &new).await;
}
async fn guild_role_update(
&self,
ctx: Context,
old_data_if_available: Option<Role>,
new: Role,
) {
role_event::handle_role_update(&ctx, old_data_if_available, &new).await;
}
async fn guild_role_delete(
&self,
ctx: Context,
guild_id: GuildId,
removed_role_id: RoleId,
removed_role_data_if_available: Option<Role>,
) {
role_event::handle_role_delete(
&ctx,
guild_id,
removed_role_id,
removed_role_data_if_available,
)
.await;
}
async fn channel_create(&self, ctx: Context, guild_channel: GuildChannel) {
channel_event::handle_channel_create(&ctx, &guild_channel).await;
}
async fn channel_update(&self, ctx: Context, old: Option<GuildChannel>, new: GuildChannel) {
channel_event::handle_channel_update(&ctx, old, &new).await;
}
async fn channel_delete(
&self,
ctx: Context,
channel: GuildChannel,
_messages: Option<Vec<Message>>,
) {
channel_event::handle_channel_delete(&ctx, &channel).await;
}
}
+45
View File
@@ -0,0 +1,45 @@
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::commands::common::parse_channel_id;
use crate::db::DbPoolKey;
pub async fn pool(ctx: &Context) -> Option<sqlx::PgPool> {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
}
pub fn parse_target_channel(msg: &Message, args: &[&str], idx: usize) -> Option<ChannelId> {
args.get(idx)
.and_then(|raw| parse_channel_id(raw))
.or(Some(msg.channel_id))
}
pub async fn set_log_channel(
ctx: &Context,
guild_id: GuildId,
log_type: &str,
channel_id: Option<ChannelId>,
enabled: bool,
) {
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let _ = sqlx::query(
r#"
INSERT INTO bot_log_channels (bot_id, guild_id, log_type, channel_id, enabled)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (bot_id, guild_id, log_type)
DO UPDATE SET channel_id = EXCLUDED.channel_id, enabled = EXCLUDED.enabled, updated_at = NOW();
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(log_type)
.bind(channel_id.map(|c| c.get() as i64))
.bind(enabled)
.execute(&pool)
.await;
}
+661
View File
@@ -0,0 +1,661 @@
use std::collections::BTreeSet;
use chrono::Utc;
use serenity::builder::CreateEmbed;
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::db::DbPoolKey;
async fn pool(ctx: &Context) -> Option<sqlx::PgPool> {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
}
async fn get_log_channel(ctx: &Context, guild_id: GuildId, log_type: &str) -> Option<ChannelId> {
let pool = pool(ctx).await?;
let bot_id = ctx.cache.current_user().id;
let row = sqlx::query_as::<_, (Option<i64>, bool)>(
r#"
SELECT channel_id, enabled
FROM bot_log_channels
WHERE bot_id = $1 AND guild_id = $2 AND log_type = $3
LIMIT 1;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(log_type)
.fetch_optional(&pool)
.await
.ok()
.flatten()?;
if !row.1 {
return None;
}
row.0
.and_then(|id| u64::try_from(id).ok().map(ChannelId::new))
}
async fn is_nolog_channel(
ctx: &Context,
guild_id: GuildId,
channel_id: ChannelId,
kind: &str,
) -> bool {
let Some(pool) = pool(ctx).await else {
return false;
};
let bot_id = ctx.cache.current_user().id;
let row = sqlx::query_as::<_, (bool, bool)>(
r#"
SELECT disable_message, disable_voice
FROM bot_nolog_channels
WHERE bot_id = $1 AND guild_id = $2 AND channel_id = $3
LIMIT 1;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(channel_id.get() as i64)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let Some((disable_message, disable_voice)) = row else {
return false;
};
match kind {
"message" => disable_message,
"voice" => disable_voice,
_ => false,
}
}
async fn record_audit_log(
ctx: &Context,
guild_id: GuildId,
log_type: &str,
user_id: Option<UserId>,
channel_id: Option<ChannelId>,
role_id: Option<RoleId>,
action: &str,
) {
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let _ = crate::db::insert_audit_log(
&pool, bot_id, guild_id, log_type, user_id, channel_id, role_id, None, action, None,
)
.await;
}
pub async fn send_log_embed(ctx: &Context, guild_id: GuildId, log_type: &str, embed: CreateEmbed) {
record_audit_log(ctx, guild_id, log_type, None, None, None, log_type).await;
if let Some(channel_id) = get_log_channel(ctx, guild_id, log_type).await {
let _ = channel_id
.send_message(
&ctx.http,
serenity::builder::CreateMessage::new().embed(embed),
)
.await;
}
}
pub async fn emit_log(
ctx: &Context,
guild_id: GuildId,
log_type: &str,
user_id: Option<UserId>,
channel_id: Option<ChannelId>,
role_id: Option<RoleId>,
action: &str,
mut embed: CreateEmbed,
) {
let timestamp = Utc::now();
embed = embed.timestamp(timestamp);
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
.send_message(
&ctx.http,
serenity::builder::CreateMessage::new().embed(embed),
)
.await;
}
}
pub async fn on_member_join(ctx: &Context, guild_id: GuildId, user: &User) {
emit_log(
ctx,
guild_id,
"raid",
Some(user.id),
None,
None,
"join",
CreateEmbed::new().title("RaidLog").description(format!(
"Nouveau membre: <@{}> (`{}`)",
user.id.get(),
user.tag()
)),
)
.await;
run_join_leave_action(ctx, guild_id, "join", user).await;
}
pub async fn on_member_leave(ctx: &Context, guild_id: GuildId, user: &User) {
run_join_leave_action(ctx, guild_id, "leave", user).await;
}
async fn run_join_leave_action(ctx: &Context, guild_id: GuildId, kind: &str, user: &User) {
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let row = sqlx::query_as::<_, (bool, Option<i64>, Option<String>)>(
r#"
SELECT enabled, channel_id, custom_message
FROM bot_join_leave_settings
WHERE bot_id = $1 AND guild_id = $2 AND kind = $3
LIMIT 1;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(kind)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let Some((enabled, channel_id, custom_message)) = row else {
return;
};
if !enabled {
return;
}
let channel_id = channel_id
.and_then(|id| u64::try_from(id).ok().map(ChannelId::new))
.unwrap_or_else(|| ChannelId::new(guild_id.get()));
let content = custom_message.unwrap_or_else(|| {
if kind == "join" {
format!("Bienvenue <@{}> !", user.id.get())
} else {
format!("<@{}> a quitté le serveur.", user.id.get())
}
});
let _ = channel_id.say(&ctx.http, content).await;
}
pub async fn send_boost_embed(ctx: &Context, guild_id: GuildId, user: &User) {
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let row = sqlx::query_as::<_, (bool, Option<String>, Option<String>, Option<i32>)>(
r#"
SELECT enabled, title, description, color
FROM bot_boost_embed
WHERE bot_id = $1 AND guild_id = $2
LIMIT 1;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let enabled = row.as_ref().map(|r| r.0).unwrap_or(true);
if !enabled {
return;
}
let title = row
.as_ref()
.and_then(|r| r.1.clone())
.unwrap_or_else(|| "Nouveau boost".to_string());
let description = row
.as_ref()
.and_then(|r| r.2.clone())
.unwrap_or_else(|| format!("<@{}> vient de booster le serveur !", user.id.get()));
let color = row
.as_ref()
.and_then(|r| r.3)
.map(|c| c.max(0) as u32)
.unwrap_or(0xF47FFF);
let embed = CreateEmbed::new()
.title(title)
.description(description)
.color(color);
if let Some(channel_id) = get_log_channel(ctx, guild_id, "boost").await {
let _ = channel_id
.send_message(
&ctx.http,
serenity::builder::CreateMessage::new().embed(embed),
)
.await;
}
}
pub async fn on_message_deleted(
ctx: &Context,
guild_id: Option<GuildId>,
channel_id: ChannelId,
message_id: MessageId,
author_id: Option<UserId>,
content: Option<String>,
) {
let Some(guild_id) = guild_id else {
return;
};
if is_nolog_channel(ctx, guild_id, channel_id, "message").await {
return;
}
let embed = CreateEmbed::new()
.title("Message supprimé")
.description(format!(
"Salon: <#{}>\nAuteur: {}\nMessage: `{}`\nContenu: {}",
channel_id.get(),
author_id
.map(|id| format!("<@{}>", id.get()))
.unwrap_or_else(|| "inconnu".to_string()),
message_id.get(),
content.unwrap_or_else(|| "(indisponible)".to_string())
));
send_log_embed(ctx, guild_id, "message", embed).await;
}
pub async fn on_message_edited(
ctx: &Context,
guild_id: Option<GuildId>,
channel_id: ChannelId,
author_id: Option<UserId>,
before: Option<String>,
after: Option<String>,
) {
let Some(guild_id) = guild_id else {
return;
};
if is_nolog_channel(ctx, guild_id, channel_id, "message").await {
return;
}
let embed = CreateEmbed::new()
.title("Message édité")
.description(format!(
"Salon: <#{}>\nAuteur: {}\nAvant: {}\nAprès: {}",
channel_id.get(),
author_id
.map(|id| format!("<@{}>", id.get()))
.unwrap_or_else(|| "inconnu".to_string()),
before.unwrap_or_else(|| "(indisponible)".to_string()),
after.unwrap_or_else(|| "(indisponible)".to_string())
));
send_log_embed(ctx, guild_id, "message", embed).await;
}
pub async fn on_voice_update(
ctx: &Context,
guild_id: GuildId,
user_id: UserId,
old_channel: Option<ChannelId>,
new_channel: Option<ChannelId>,
) {
if let Some(ch) = new_channel.or(old_channel) {
if is_nolog_channel(ctx, guild_id, ch, "voice").await {
return;
}
}
let action = match (old_channel, new_channel) {
(None, Some(to)) => format!("<@{}> a rejoint <#{}>", user_id.get(), to.get()),
(Some(from), None) => format!("<@{}> a quitté <#{}>", user_id.get(), from.get()),
(Some(from), Some(to)) => format!(
"<@{}> a bougé de <#{}> vers <#{}>",
user_id.get(),
from.get(),
to.get()
),
_ => return,
};
send_log_embed(
ctx,
guild_id,
"voice",
CreateEmbed::new().title("VoiceLog").description(action),
)
.await;
}
pub async fn on_role_create(ctx: &Context, guild_id: GuildId, role: &Role) {
send_log_embed(
ctx,
guild_id,
"role",
CreateEmbed::new().title("Role créé").description(format!(
"<@&{}> (`{}`)",
role.id.get(),
role.name
)),
)
.await;
}
pub async fn on_role_update(
ctx: &Context,
guild_id: GuildId,
old_role: Option<&Role>,
new_role: &Role,
) {
let desc = if let Some(old) = old_role {
format!(
"`{}` -> `{}`\nID: <@&{}>",
old.name,
new_role.name,
new_role.id.get()
)
} else {
format!("Role mis à jour: <@&{}>", new_role.id.get())
};
send_log_embed(
ctx,
guild_id,
"role",
CreateEmbed::new().title("Role modifié").description(desc),
)
.await;
}
pub async fn on_role_delete(
ctx: &Context,
guild_id: GuildId,
role_id: RoleId,
role: Option<&Role>,
) {
let desc = role
.map(|r| format!("`{}` (`{}`)", r.name, role_id.get()))
.unwrap_or_else(|| format!("ID `{}`", role_id.get()));
send_log_embed(
ctx,
guild_id,
"role",
CreateEmbed::new().title("Role supprimé").description(desc),
)
.await;
}
pub async fn on_member_roles_updated(
ctx: &Context,
guild_id: GuildId,
user_id: UserId,
old_roles: &[RoleId],
new_roles: &[RoleId],
) {
let old_set = old_roles.iter().copied().collect::<BTreeSet<_>>();
let new_set = new_roles.iter().copied().collect::<BTreeSet<_>>();
let added = new_set
.difference(&old_set)
.map(|r| format!("<@&{}>", r.get()))
.collect::<Vec<_>>();
let removed = old_set
.difference(&new_set)
.map(|r| format!("<@&{}>", r.get()))
.collect::<Vec<_>>();
if added.is_empty() && removed.is_empty() {
return;
}
let desc = format!(
"Membre: <@{}>\nAjoutés: {}\nRetirés: {}",
user_id.get(),
if added.is_empty() {
"aucun".to_string()
} else {
added.join(", ")
},
if removed.is_empty() {
"aucun".to_string()
} else {
removed.join(", ")
}
);
send_log_embed(
ctx,
guild_id,
"role",
CreateEmbed::new().title("RoleLog membre").description(desc),
)
.await;
}
pub async fn on_boost_update(
ctx: &Context,
guild_id: GuildId,
user_id: UserId,
old_boost: Option<Timestamp>,
new_boost: Option<Timestamp>,
) {
match (old_boost, new_boost) {
(None, Some(_)) => {
send_log_embed(
ctx,
guild_id,
"boost",
CreateEmbed::new()
.title("Nouveau boost")
.description(format!("<@{}> a boost le serveur.", user_id.get())),
)
.await;
if let Ok(user) = ctx.http.get_user(user_id).await {
send_boost_embed(ctx, guild_id, &user).await;
}
}
(Some(_), None) => {
send_log_embed(
ctx,
guild_id,
"boost",
CreateEmbed::new()
.title("Boost retiré")
.description(format!("<@{}> ne boost plus le serveur.", user_id.get())),
)
.await;
}
_ => {}
}
}
pub async fn log_moderation_command(ctx: &Context, msg: &Message, command: &str, args: &[&str]) {
let Some(guild_id) = msg.guild_id else {
return;
};
let enabled = is_modlog_event_enabled(ctx, guild_id, command).await;
if !enabled {
return;
}
let content = if args.is_empty() {
command.to_string()
} else {
format!("{} {}", command, args.join(" "))
};
emit_log(
ctx,
guild_id,
"moderation",
Some(msg.author.id),
Some(msg.channel_id),
None,
command,
CreateEmbed::new().title("ModLog").description(format!(
"Modérateur: <@{}>\nCommande: `+{}`",
msg.author.id.get(),
content
)),
)
.await;
}
async fn is_modlog_event_enabled(ctx: &Context, guild_id: GuildId, event: &str) -> bool {
let Some(pool) = pool(ctx).await else {
return true;
};
let bot_id = ctx.cache.current_user().id;
let row = sqlx::query_as::<_, (String,)>(
r#"
SELECT modlog_events
FROM bot_log_settings
WHERE bot_id = $1 AND guild_id = $2
LIMIT 1;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.fetch_optional(&pool)
.await
.ok()
.flatten();
let Some((events,)) = row else {
return true;
};
let set = events
.split(',')
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty())
.collect::<BTreeSet<_>>();
set.contains(&event.to_lowercase())
}
pub async fn on_channel_create(ctx: &Context, channel: &GuildChannel) {
emit_log(
ctx,
channel.guild_id,
"channel",
None,
Some(channel.id),
None,
"créé",
CreateEmbed::new()
.title("Channel Créé")
.description(format!(
"Salon: <#{}> \nNom: {} \nType: {}",
channel.id.get(),
channel.name,
match channel.kind {
ChannelType::Text => "Texte",
ChannelType::Voice => "Vocal",
ChannelType::Category => "Catégorie",
_ => "Autre",
}
)),
)
.await;
}
pub async fn on_channel_delete(ctx: &Context, channel: &GuildChannel) {
emit_log(
ctx,
channel.guild_id,
"channel",
None,
Some(channel.id),
None,
"supprimé",
CreateEmbed::new()
.title("Channel Supprimé")
.description(format!(
"Salon: {}\nNom: {}\nType: {}",
channel.id.get(),
channel.name,
match channel.kind {
ChannelType::Text => "Texte",
ChannelType::Voice => "Vocal",
ChannelType::Category => "Catégorie",
_ => "Autre",
}
)),
)
.await;
}
pub async fn on_channel_update(ctx: &Context, old_data: Option<GuildChannel>, new: &GuildChannel) {
let mut changes = Vec::new();
if let Some(old) = old_data {
if old.name != new.name {
changes.push(format!("Nom: `{}` → `{}`", old.name, new.name));
}
if old.topic != new.topic {
changes.push(format!(
"Sujet: `{}` → `{}`",
old.topic.as_deref().unwrap_or("(vide)"),
new.topic.as_deref().unwrap_or("(vide)")
));
}
if old.nsfw != new.nsfw {
changes.push(format!("NSFW: {}{}", old.nsfw, new.nsfw));
}
}
if changes.is_empty() {
return;
}
emit_log(
ctx,
new.guild_id,
"channel",
None,
Some(new.id),
None,
"modifié",
CreateEmbed::new()
.title("Channel Modifié")
.description(format!(
"Salon: <#{}>\n{}",
new.id.get(),
changes.join("\n")
)),
)
.await;
}
+67
View File
@@ -0,0 +1,67 @@
use serenity::model::prelude::*;
use serenity::prelude::*;
pub async fn edit_channel_visibility(
ctx: &Context,
guild_id: GuildId,
channel_id: ChannelId,
lock: Option<bool>,
hide: Option<bool>,
) -> bool {
let Ok(guild) = guild_id.to_partial_guild(&ctx.http).await else {
return false;
};
let everyone_role = guild
.roles
.values()
.find(|r| r.name == "@everyone")
.map(|r| r.id);
let Some(everyone_role) = everyone_role else {
return false;
};
let Ok(channels) = guild_id.channels(&ctx.http).await else {
return false;
};
let Some(channel) = channels.get(&channel_id) else {
return false;
};
let mut allow = Permissions::empty();
let mut deny = Permissions::empty();
if let Some(locked) = lock {
if channel.kind == ChannelType::Text || channel.kind == ChannelType::News {
if locked {
deny |= Permissions::SEND_MESSAGES;
} else {
allow |= Permissions::SEND_MESSAGES;
}
} else if locked {
deny |= Permissions::CONNECT | Permissions::SPEAK;
} else {
allow |= Permissions::CONNECT | Permissions::SPEAK;
}
}
if let Some(hidden) = hide {
if hidden {
deny |= Permissions::VIEW_CHANNEL;
} else {
allow |= Permissions::VIEW_CHANNEL;
}
}
channel_id
.create_permission(
&ctx.http,
PermissionOverwrite {
allow,
deny,
kind: PermissionOverwriteType::Role(everyone_role),
},
)
.await
.is_ok()
}
+186
View File
@@ -0,0 +1,186 @@
use std::time::Duration;
use chrono::Utc;
use serenity::builder::EditMember;
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::commands::admin_common::parse_user_id;
use crate::db::{self, DbPoolKey};
pub fn duration_from_input(input: &str) -> Option<Duration> {
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::<u64>().ok()?;
let secs = match suffix.as_str() {
"s" | "sec" | "secs" | "seconde" | "secondes" => value,
"m" | "min" | "mins" | "minute" | "minutes" => value * 60,
"h" | "heure" | "heures" => value * 3600,
"j" | "d" | "jour" | "jours" => value * 86400,
_ => return None,
};
Some(Duration::from_secs(secs.max(1)))
}
pub async fn pool(ctx: &Context) -> Option<sqlx::PgPool> {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
}
pub async fn add_sanction(
ctx: &Context,
guild_id: GuildId,
user_id: UserId,
moderator_id: UserId,
kind: &str,
reason: &str,
channel_id: Option<ChannelId>,
expires_at: Option<chrono::DateTime<Utc>>,
) {
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let _ = sqlx::query(
r#"
INSERT INTO bot_sanctions
(bot_id, guild_id, user_id, moderator_id, kind, reason, channel_id, expires_at, active)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, TRUE);
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(user_id.get() as i64)
.bind(moderator_id.get() as i64)
.bind(kind)
.bind(reason)
.bind(channel_id.map(|c| c.get() as i64))
.bind(expires_at)
.execute(&pool)
.await;
}
pub async fn parse_targets(raw: &str) -> Vec<UserId> {
let mut out = Vec::new();
for token in raw.split(',') {
if let Some(uid) = parse_user_id(token.trim()) {
out.push(uid);
}
}
out
}
pub async fn handle_timeout(
ctx: &Context,
guild_id: GuildId,
users: &[UserId],
expires: Option<chrono::DateTime<Utc>>,
) -> 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 {
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 {
let Some(role_id) = mute_role_id else {
continue;
};
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;
}
}
}
}
done
}
pub 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
}
+71
View File
@@ -0,0 +1,71 @@
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use chrono::Utc;
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<Mutex<Instant>> = OnceLock::new();
async fn pool(ctx: &Context) -> Option<sqlx::PgPool> {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
}
pub async fn maybe_run_maintenance(ctx: &Context, guild_id: Option<GuildId>) {
let Some(guild_id) = guild_id else {
return;
};
let now = Instant::now();
let lock = MODERATION_TICK.get_or_init(|| Mutex::new(Instant::now() - Duration::from_secs(60)));
{
let mut last = lock.lock().expect("moderation tick lock poisoned");
if now.duration_since(*last) < Duration::from_secs(30) {
return;
}
*last = now;
}
let Some(pool) = pool(ctx).await else {
return;
};
let bot_id = ctx.cache.current_user().id;
let now_dt = Utc::now();
let rows = sqlx::query_as::<_, (i64, i64, String, Option<i64>)>(
r#"
SELECT id, user_id, kind, channel_id
FROM bot_sanctions
WHERE bot_id = $1 AND guild_id = $2 AND active = TRUE AND expires_at IS NOT NULL AND expires_at <= $3;
"#,
)
.bind(bot_id.get() as i64)
.bind(guild_id.get() as i64)
.bind(now_dt)
.fetch_all(&pool)
.await
.unwrap_or_default();
for (id, uid, kind, channel_id) in &rows {
let user_id = UserId::new(*uid as u64);
if kind == "tempmute" {
let _ = handle_timeout(ctx, guild_id, &[user_id], None).await;
} else if kind == "tempcmute" {
if let Some(cid) = channel_id {
let _ =
channel_mute_users(ctx, ChannelId::new(*cid as u64), &[user_id], false).await;
}
} else if kind == "tempban" {
let _ = guild_id.unban(&ctx.http, user_id).await;
}
let _ = sqlx::query("UPDATE bot_sanctions SET active = FALSE WHERE id = $1")
.bind(*id)
.execute(&pool)
.await;
}
}
+74
View File
@@ -0,0 +1,74 @@
use serenity::builder::CreateEmbed;
use serenity::model::prelude::*;
use serenity::prelude::*;
use sqlx::PgPool;
use crate::commands::common::send_embed;
use crate::db::DbPoolKey;
use crate::permissions::is_owner_user;
pub fn parse_user_or_role(input: &str) -> Option<(&'static str, u64)> {
let trimmed = input.trim();
if trimmed.starts_with("<@&") && trimmed.ends_with('>') {
return trimmed
.trim_start_matches("<@&")
.trim_end_matches('>')
.parse::<u64>()
.ok()
.map(|id| ("role", id));
}
if (trimmed.starts_with("<@") && trimmed.ends_with('>')) || trimmed.parse::<u64>().is_ok() {
let cleaned = trimmed
.trim_start_matches('<')
.trim_end_matches('>')
.trim_start_matches('@')
.trim_start_matches('!');
if let Ok(id) = cleaned.parse::<u64>() {
return Some(("user", id));
}
}
None
}
pub fn normalize_command_name(input: &str) -> String {
let normalized = input.trim_start_matches('+').to_lowercase();
let underscored = normalized
.split_whitespace()
.collect::<Vec<_>>()
.join("_");
let compact = underscored.replace('_', "");
let known = crate::permissions::all_command_keys();
let direct = crate::permissions::command_key(&underscored, &[]);
if known.iter().any(|value| value == &direct) {
return direct;
}
let compact_mapped = crate::permissions::command_key(&compact, &[]);
if known.iter().any(|value| value == &compact_mapped) {
return compact_mapped;
}
underscored
}
pub async fn ensure_owner(ctx: &Context, msg: &Message) -> bool {
if is_owner_user(ctx, msg.author.id).await {
true
} else {
let embed = CreateEmbed::new()
.title("Acces refuse")
.description("Commande reservee aux owners.")
.color(0xED4245);
send_embed(ctx, msg, embed).await;
false
}
}
pub async fn get_pool(ctx: &Context) -> Option<PgPool> {
let data = ctx.data.read().await;
data.get::<DbPoolKey>().cloned()
}
+147
View File
@@ -0,0 +1,147 @@
use serenity::builder::{
CreateActionRow, CreateButton, CreateEmbed, CreateInteractionResponse,
CreateInteractionResponseMessage,
};
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::commands::common::{theme_color, truncate_text};
use crate::permissions::{all_command_keys, command_required_permission, default_permission};
const ALLPERMS_PAGE_SIZE: usize = 12;
const ALLPERMS_CUSTOM_ID_PREFIX: &str = "allperms";
pub async fn handle_allperms_component(ctx: &Context, component: &ComponentInteraction) -> bool {
let Some((owner_id, requested_page)) = parse_allperms_custom_id(&component.data.custom_id)
else {
return false;
};
if component.user.id.get() != owner_id {
let _ = component
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("Seul l'auteur de la commande peut utiliser ces boutons.")
.ephemeral(true),
),
)
.await;
return true;
}
let lines = collect_allperms_lines(ctx).await;
let total_pages = total_pages_for(lines.len());
let page = requested_page.min(total_pages.saturating_sub(1));
let color = theme_color(ctx).await;
let embed = build_allperms_embed(&lines, page, color);
let components = allperms_components(component.user.id, page, total_pages);
let _ = component
.create_response(
&ctx.http,
CreateInteractionResponse::UpdateMessage(
CreateInteractionResponseMessage::new()
.embed(embed)
.components(components),
),
)
.await;
true
}
async fn collect_allperms_lines(ctx: &Context) -> Vec<String> {
let mut commands = all_command_keys();
if !commands.iter().any(|c| c == "allperms") {
commands.push("allperms".to_string());
}
commands.sort();
let mut lines = Vec::with_capacity(commands.len());
for cmd in commands {
let required = command_required_permission(ctx, &cmd).await;
let default = default_permission(&cmd);
if required == default {
lines.push(format!("`{}` -> `{}`", cmd, required));
} else {
lines.push(format!(
"`{}` -> `{}` (defaut `{}`)",
cmd, required, default
));
}
}
lines
}
fn total_pages_for(total_items: usize) -> usize {
((total_items + ALLPERMS_PAGE_SIZE.saturating_sub(1)) / ALLPERMS_PAGE_SIZE).max(1)
}
fn build_allperms_embed(lines: &[String], page: usize, color: u32) -> CreateEmbed {
let total_pages = total_pages_for(lines.len());
let safe_page = page.min(total_pages.saturating_sub(1));
let start = safe_page * ALLPERMS_PAGE_SIZE;
let end = (start + ALLPERMS_PAGE_SIZE).min(lines.len());
let chunk = if start < end { &lines[start..end] } else { &[] };
let value = if chunk.is_empty() {
"Aucune commande.".to_string()
} else {
truncate_text(&chunk.join("\n"), 1024)
};
CreateEmbed::new()
.title("Permissions de toutes les commandes")
.description(format!(
"{} commande(s) · Page {}/{}",
lines.len(),
safe_page + 1,
total_pages
))
.field("Niveaux requis", value, false)
.color(color)
}
fn allperms_components(owner_id: UserId, page: usize, total_pages: usize) -> Vec<CreateActionRow> {
let safe_total = total_pages.max(1);
let safe_page = page.min(safe_total.saturating_sub(1));
let prev_page = safe_page.saturating_sub(1);
let next_page = (safe_page + 1).min(safe_total.saturating_sub(1));
vec![CreateActionRow::Buttons(vec![
CreateButton::new(format!(
"{}:{}:{}",
ALLPERMS_CUSTOM_ID_PREFIX,
owner_id.get(),
prev_page
))
.label("◀ Precedent")
.style(ButtonStyle::Primary)
.disabled(safe_page == 0),
CreateButton::new(format!(
"{}:{}:{}",
ALLPERMS_CUSTOM_ID_PREFIX,
owner_id.get(),
next_page
))
.label("Suivant ▶")
.style(ButtonStyle::Primary)
.disabled(safe_page + 1 >= safe_total),
])]
}
fn parse_allperms_custom_id(custom_id: &str) -> Option<(u64, usize)> {
let mut parts = custom_id.split(':');
let prefix = parts.next()?;
if prefix != ALLPERMS_CUSTOM_ID_PREFIX {
return None;
}
let owner_id = parts.next()?.parse::<u64>().ok()?;
let page = parts.next()?.parse::<usize>().ok()?;
Some((owner_id, page))
}
+32
View File
@@ -0,0 +1,32 @@
// Gestion centralisée des slash commands
use serenity::model::prelude::*;
use serenity::prelude::*;
use crate::commands::{autopublish, showpics, suggestion, tempvoc, ticket, tickets};
pub async fn handle_slash_commands(ctx: &Context, interaction: &Interaction) -> bool {
if ticket::handle_slash_interaction(ctx, interaction).await {
return true;
}
if tickets::handle_slash_interaction(ctx, interaction).await {
return true;
}
if showpics::handle_slash_interaction(ctx, interaction).await {
return true;
}
if suggestion::handle_slash_interaction(ctx, interaction).await {
return true;
}
if autopublish::handle_slash_interaction(ctx, interaction).await {
return true;
}
if tempvoc::handle_slash_interaction(ctx, interaction).await {
return true;
}
false
}