feat(moderation): mute/unmute timeout+role, purge, mod-logs join/leave/edit/delete, RateLimiter+JobQueue pre-check
This commit is contained in:
parent
b4a19e01e4
commit
bfdc5ce282
6 changed files with 325 additions and 2 deletions
|
|
@ -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<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(
|
||||
|
|
|
|||
|
|
@ -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<RateLimiter> = 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()
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -123,5 +123,46 @@ pub async fn warn(
|
|||
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).
|
||||
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);
|
||||
|
|
|
|||
73
src/feature/moderation/logging.rs
Normal file
73
src/feature/moderation/logging.rs
Normal 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;
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
pub mod command;
|
||||
pub mod logging;
|
||||
pub mod mute;
|
||||
|
|
|
|||
110
src/feature/moderation/mute.rs
Normal file
110
src/feature/moderation/mute.rs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue