feat(moderation): mute/unmute timeout+role, purge, mod-logs join/leave/edit/delete, RateLimiter+JobQueue pre-check

This commit is contained in:
loki5512344 2026-09-21 22:09:09 +02:00
parent b4a19e01e4
commit bfdc5ce282
Signed by: boba
GPG key ID: 253067914055423B
6 changed files with 325 additions and 2 deletions

View file

@ -72,9 +72,66 @@ impl EventHandler for Handler {
} }
async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) { 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::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<serenity::all::Member>,
) {
crate::feature::moderation::logging::on_member_leave(&ctx, &user, Some(guild_id)).await;
}
async fn message_update(
&self,
ctx: Context,
_old: Option<serenity::all::Message>,
new: Option<serenity::all::Message>,
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<serenity::all::GuildId>,
) {
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<serenity::all::MessageId>,
guild_id: Option<serenity::all::GuildId>,
) {
let _ = guild_id;
crate::feature::moderation::logging::on_message_delete_bulk(
&ctx,
channel_id,
&multiple_deleted_messages_ids,
)
.await;
} }
async fn voice_state_update( async fn voice_state_update(

View file

@ -13,8 +13,15 @@ pub mod voice;
pub mod welcome; pub mod welcome;
use crate::config::BotConfig; use crate::config::BotConfig;
use crate::core::{dispatcher::RateLimiter, policy};
use poise::Framework; use poise::Framework;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::OnceLock;
static RATE_LIMITER: OnceLock<RateLimiter> = OnceLock::new();
fn limiter() -> &'static RateLimiter {
RATE_LIMITER.get_or_init(|| RateLimiter::new(policy::RATE_LIMIT_WINDOW_SECS))
}
pub struct Data { pub struct Data {
pub cfg: BotConfig, pub cfg: BotConfig,
@ -42,8 +49,11 @@ pub async fn build_framework(
ticket::command::ticket_add(), ticket::command::ticket_add(),
ticket::command::ticket_remove(), ticket::command::ticket_remove(),
moderation::command::clear(), moderation::command::clear(),
moderation::command::purge(),
moderation::command::slowmode(), moderation::command::slowmode(),
moderation::command::warn(), moderation::command::warn(),
moderation::mute::mute(),
moderation::mute::unmute(),
blog::command::blog_post(), blog::command::blog_post(),
poll::command::poll(), poll::command::poll(),
remind::command::remind(), remind::command::remind(),
@ -52,6 +62,36 @@ pub async fn build_framework(
prefix: Some(cfg.prefix.clone().into()), prefix: Some(cfg.prefix.clone().into()),
..Default::default() ..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() ..Default::default()
}; };

View file

@ -123,5 +123,46 @@ pub async fn warn(
Ok(()) Ok(())
} }
/// `/purge <amount> [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<serenity::all::User>,
) -> 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). // Keep helper to avoid unused warning (used via get_lang).
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of); const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);

View file

@ -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<serenity::all::GuildId>,
) {
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::<String>()
))
.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<serenity::all::GuildId>,
) {
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;
}

View file

@ -1 +1,3 @@
pub mod command; pub mod command;
pub mod logging;
pub mod mute;

View file

@ -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<std::time::Duration> {
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<String>,
) -> 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;
}