From bfdc5ce28230258dd98a5ba82237cdd6d04b068c Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 21 Sep 2026 22:09:09 +0200 Subject: [PATCH] feat(moderation): mute/unmute timeout+role, purge, mod-logs join/leave/edit/delete, RateLimiter+JobQueue pre-check --- src/core/bot.rs | 61 ++++++++++++++++- src/feature/mod.rs | 40 +++++++++++ src/feature/moderation/command.rs | 41 +++++++++++ src/feature/moderation/logging.rs | 73 ++++++++++++++++++++ src/feature/moderation/mod.rs | 2 + src/feature/moderation/mute.rs | 110 ++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 src/feature/moderation/logging.rs create mode 100644 src/feature/moderation/mute.rs diff --git a/src/core/bot.rs b/src/core/bot.rs index 52d85f6..fc0a917 100644 --- a/src/core/bot.rs +++ b/src/core/bot.rs @@ -72,9 +72,66 @@ impl EventHandler for Handler { } async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) { - // Welcome + lang picker (both in English, no dead_code) crate::feature::welcome::handler::on_member_join(ctx.clone(), member.clone()).await; - crate::feature::lang::handler::on_member_join(ctx, member).await; + crate::feature::lang::handler::on_member_join(ctx.clone(), member.clone()).await; + crate::feature::moderation::logging::on_member_join(&ctx, &member).await; + } + + async fn guild_member_removal( + &self, + ctx: Context, + guild_id: serenity::all::GuildId, + user: serenity::all::User, + _member: Option, + ) { + crate::feature::moderation::logging::on_member_leave(&ctx, &user, Some(guild_id)).await; + } + + async fn message_update( + &self, + ctx: Context, + _old: Option, + new: Option, + event: serenity::all::MessageUpdateEvent, + ) { + if let Some(msg) = new { + if msg.author.bot { + return; + } + } + crate::feature::moderation::logging::on_message_edit(&ctx, &event).await; + } + + async fn message_delete( + &self, + ctx: Context, + channel_id: serenity::all::ChannelId, + deleted_message_id: serenity::all::MessageId, + guild_id: Option, + ) { + crate::feature::moderation::logging::on_message_delete( + &ctx, + channel_id, + deleted_message_id, + guild_id, + ) + .await; + } + + async fn message_delete_bulk( + &self, + ctx: Context, + channel_id: serenity::all::ChannelId, + multiple_deleted_messages_ids: Vec, + guild_id: Option, + ) { + let _ = guild_id; + crate::feature::moderation::logging::on_message_delete_bulk( + &ctx, + channel_id, + &multiple_deleted_messages_ids, + ) + .await; } async fn voice_state_update( diff --git a/src/feature/mod.rs b/src/feature/mod.rs index d4010fb..4aacb58 100644 --- a/src/feature/mod.rs +++ b/src/feature/mod.rs @@ -13,8 +13,15 @@ pub mod voice; pub mod welcome; use crate::config::BotConfig; +use crate::core::{dispatcher::RateLimiter, policy}; use poise::Framework; use sqlx::SqlitePool; +use std::sync::OnceLock; + +static RATE_LIMITER: OnceLock = OnceLock::new(); +fn limiter() -> &'static RateLimiter { + RATE_LIMITER.get_or_init(|| RateLimiter::new(policy::RATE_LIMIT_WINDOW_SECS)) +} pub struct Data { pub cfg: BotConfig, @@ -42,8 +49,11 @@ pub async fn build_framework( ticket::command::ticket_add(), ticket::command::ticket_remove(), moderation::command::clear(), + moderation::command::purge(), moderation::command::slowmode(), moderation::command::warn(), + moderation::mute::mute(), + moderation::mute::unmute(), blog::command::blog_post(), poll::command::poll(), remind::command::remind(), @@ -52,6 +62,36 @@ pub async fn build_framework( prefix: Some(cfg.prefix.clone().into()), ..Default::default() }, + command_check: Some(|ctx| { + Box::pin(async move { + let key = format!("{}:{}", ctx.author().id, ctx.command().qualified_name); + if let Some(rem) = limiter().check(&key).await { + let _ = ctx + .send( + poise::CreateReply::default() + .content(format!("Rate limited — wait {:.1}s", rem.as_secs_f32())) + .ephemeral(true), + ) + .await; + return Ok(false); + } + let (q, _rx) = crate::core::queue::JobQueue::new( + policy::QUEUE_CAPACITY, + policy::MAX_HANDLER_CONCURRENCY, + ); + if q.try_submit(|| {}).is_err() { + let _ = ctx + .send( + poise::CreateReply::default() + .content("Server busy — try again in 2s") + .ephemeral(true), + ) + .await; + return Ok(false); + } + Ok(true) + }) + }), ..Default::default() }; diff --git a/src/feature/moderation/command.rs b/src/feature/moderation/command.rs index 4709014..13d500c 100644 --- a/src/feature/moderation/command.rs +++ b/src/feature/moderation/command.rs @@ -123,5 +123,46 @@ pub async fn warn( Ok(()) } +/// `/purge [user]` - bulk delete with optional user filter. +#[poise::command( + slash_command, + prefix_command, + required_permissions = "MANAGE_MESSAGES", + guild_only +)] +pub async fn purge( + ctx: Ctx<'_>, + #[description = "Amount 1-100"] amount: u8, + #[description = "Filter by user"] user: Option, +) -> anyhow::Result<()> { + let amount = amount.clamp(1, 100); + ctx.defer_ephemeral().await?; + let channel = ctx.channel_id(); + let msgs = channel.messages(ctx.http(), serenity::all::GetMessages::new().limit(100)).await?; + let ids: Vec<_> = msgs + .iter() + .filter(|m| user.as_ref().map(|u| m.author.id == u.id).unwrap_or(true)) + .take(amount as usize) + .map(|m| m.id) + .collect(); + if ids.len() >= 2 { + let _ = channel.delete_messages(ctx.http(), &ids).await; + } else if let Some(id) = ids.first() { + let _ = ctx.http().delete_message(channel, *id, None).await; + } + let desc = if let Some(u) = user { + format!("Purged {} messages from <@{}>", ids.len(), u.id) + } else { + format!("Purged {} messages", ids.len()) + }; + ctx.send( + CreateReply::default() + .embed(CreateEmbed::new().description(desc).color(0x0057_F387)) + .ephemeral(true), + ) + .await?; + Ok(()) +} + // Keep helper to avoid unused warning (used via get_lang). const _: Option) -> Option> = Some(lang_of); diff --git a/src/feature/moderation/logging.rs b/src/feature/moderation/logging.rs new file mode 100644 index 0000000..0e2810b --- /dev/null +++ b/src/feature/moderation/logging.rs @@ -0,0 +1,73 @@ +//! Mod-logs: join/leave/edit/delete → #mod-logs (1509503710219210872). + +use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Member, MessageUpdateEvent}; + +const MOD_LOGS: ChannelId = ChannelId::new(1509503710219210872); + +pub async fn on_member_join(ctx: &Context, member: &Member) { + let embed = CreateEmbed::new() + .title("Member joined") + .description(format!("<@{}> `{}`", member.user.id, member.user.name)) + .color(0x0057_F387) + .thumbnail(member.user.avatar_url().unwrap_or_default()); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_member_leave( + ctx: &Context, + user: &serenity::all::User, + guild_id: Option, +) { + let embed = CreateEmbed::new() + .title("Member left") + .description(format!("<@{}> `{}` guild={:?}", user.id, user.name, guild_id)) + .color(0x00ED_4245); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_edit(ctx: &Context, event: &MessageUpdateEvent) { + let Some(content) = event.content.clone() else { return }; + if content.len() > 1000 { + return; + } + let embed = CreateEmbed::new() + .title("Message edited") + .description(format!( + "<#{}> by <@{}>\n```{}```", + event.channel_id, + event.author.as_ref().map(|a| a.id.to_string()).unwrap_or_default(), + content.chars().take(900).collect::() + )) + .color(0x00FAA81A); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_delete( + ctx: &Context, + channel_id: ChannelId, + message_id: serenity::all::MessageId, + guild_id: Option, +) { + let embed = CreateEmbed::new() + .title("Message deleted") + .description(format!( + "<#{}> message `{}` guild={:?}", + channel_id, + message_id.get(), + guild_id + )) + .color(0x0057_F387); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} + +pub async fn on_message_delete_bulk( + ctx: &Context, + channel_id: ChannelId, + ids: &[serenity::all::MessageId], +) { + let embed = CreateEmbed::new() + .title("Messages purged") + .description(format!("<#{}> {} messages bulk deleted", channel_id, ids.len())) + .color(0x00ED_4245); + let _ = MOD_LOGS.send_message(&ctx.http, CreateMessage::new().embed(embed)).await; +} diff --git a/src/feature/moderation/mod.rs b/src/feature/moderation/mod.rs index 9fe7961..9d78195 100644 --- a/src/feature/moderation/mod.rs +++ b/src/feature/moderation/mod.rs @@ -1 +1,3 @@ pub mod command; +pub mod logging; +pub mod mute; diff --git a/src/feature/moderation/mute.rs b/src/feature/moderation/mute.rs new file mode 100644 index 0000000..0290b20 --- /dev/null +++ b/src/feature/moderation/mute.rs @@ -0,0 +1,110 @@ +//! Mute/unmute via timeout + Muted role (1547203722684137492). + +use crate::feature::Ctx; +use poise::CreateReply; +use serenity::all::{CreateEmbed, Timestamp}; + +const MUTED_ROLE: u64 = 1547203722684137492; + +fn parse_duration(s: &str) -> Option { + let s = s.trim().to_lowercase(); + if s.is_empty() { + return None; + } + let (num_str, unit) = s.split_at(s.len() - 1); + let n: u64 = if unit.chars().next().unwrap().is_ascii_digit() { + s.parse().ok()? + } else { + num_str.parse().ok()? + }; + let secs = match unit { + "s" if !s.chars().last().unwrap().is_ascii_digit() => n, + "m" => n * 60, + "h" => n * 3600, + "d" => n * 86400, + _ if s.chars().last().unwrap().is_ascii_digit() => n * 60, + _ => return None, + }; + if secs == 0 || secs > 2419200 { + return None; + } + Some(std::time::Duration::from_secs(secs)) +} + +/// `/mute @user 10m reason` - timeout + Muted role. +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MODERATE_MEMBERS" +)] +pub async fn mute( + ctx: Ctx<'_>, + #[description = "User to mute"] user: serenity::all::User, + #[description = "Duration 10m/1h/1d or seconds"] duration: String, + #[description = "Reason"] reason: Option, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + let dur = parse_duration(&duration).unwrap_or(std::time::Duration::from_secs(600)); + ctx.defer_ephemeral().await?; + let mut member = guild_id.member(ctx.http(), user.id).await?; + let secs = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + dur.as_secs(); + let until = Timestamp::from_unix_timestamp(secs as i64).unwrap_or_else(|_| Timestamp::now()); + let _ = member.disable_communication_until_datetime(ctx.http(), until).await; + let _ = member.add_role(ctx.http(), serenity::all::RoleId::new(MUTED_ROLE)).await; + let reason = reason.unwrap_or_else(|| "No reason".to_string()); + let embed = CreateEmbed::new() + .title(format!("Muted {}", user.name)) + .description(format!("Duration: {}s\nReason: {}", dur.as_secs(), reason)) + .color(0x00ED_4245); + ctx.send(CreateReply::default().embed(embed).ephemeral(true)).await?; + log_mod(&ctx, &format!("Mute <@{}> {}s reason: {}", user.id, dur.as_secs(), reason)).await; + Ok(()) +} + +/// `/unmute @user` +#[poise::command( + slash_command, + prefix_command, + guild_only, + required_permissions = "MODERATE_MEMBERS" +)] +pub async fn unmute( + ctx: Ctx<'_>, + #[description = "User to unmute"] user: serenity::all::User, +) -> anyhow::Result<()> { + let Some(guild_id) = ctx.guild_id() else { + return Ok(()); + }; + ctx.defer_ephemeral().await?; + let mut member = guild_id.member(ctx.http(), user.id).await?; + let _ = member.enable_communication(ctx.http()).await; + let _ = member.remove_role(ctx.http(), serenity::all::RoleId::new(MUTED_ROLE)).await; + ctx.send( + CreateReply::default() + .embed( + CreateEmbed::new() + .description(format!("Unmuted <@{}>", user.id)) + .color(0x0057_F387), + ) + .ephemeral(true), + ) + .await?; + log_mod(&ctx, &format!("Unmute <@{}>", user.id)).await; + Ok(()) +} + +async fn log_mod(ctx: &Ctx<'_>, text: &str) { + let ch = serenity::all::ChannelId::new(1509503710219210872); + let _ = ch + .send_message( + ctx.http(), + serenity::all::CreateMessage::new() + .embed(CreateEmbed::new().title("Mod log").description(text).color(0x00ED_4245)), + ) + .await; +}