//! Timeoutinator — a Discord bot that instantly timeouts anyone who posts //! in a configured channel and purges that user's messages from the last //! 24 hours. use std::collections::HashSet; use std::env; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use chrono::{Duration as ChronoDuration, Utc}; use serenity::async_trait; use serenity::builder::GetMessages; use serenity::client::{Client, Context, EventHandler}; use serenity::http::Http; use serenity::model::channel::{Message, MessageType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; use serenity::model::Timestamp; use serenity::prelude::GatewayIntents; /// Discord's `GET /channels/{id}/messages` max page size. const PAGE_SIZE: u8 = 100; /// `DELETE /channels/{id}/messages` accepts at most 100 IDs per request. const BULK_DELETE_MAX: usize = 100; /// The bot's configuration and shared state. struct Handler { /// Channel whose messages trigger the timeout + purge. target_channel: ChannelId, /// How long the timeout lasts (minutes). timeout_minutes: i64, /// Purge window for the offender's messages (hours). purge_hours: i64, /// The bot's own user ID, captured on `Ready` (the cache feature is /// disabled, so we track it manually). self_id: AtomicU64, /// Users currently being processed, so a rapid spam click doesn't stack /// concurrent purge/timeout jobs for the same user. in_flight: Mutex>, } /// RAII guard that frees a user's in-flight slot when the handler is done. struct InFlightGuard<'a>(&'a Mutex>, UserId); impl<'a> InFlightGuard<'a> { fn try_acquire(lock: &'a Mutex>, user: UserId) -> Option { let mut set = lock.lock().ok()?; if set.contains(&user) { None } else { set.insert(user); Some(Self(lock, user)) } } } impl Drop for InFlightGuard<'_> { fn drop(&mut self) { if let Ok(mut set) = self.0.lock() { set.remove(&self.1); } } } #[async_trait] impl EventHandler for Handler { async fn ready(&self, _ctx: Context, ready: Ready) { self.self_id.store(ready.user.id.get(), Ordering::Relaxed); println!("Logged in as {}", ready.user.name); } async fn message(&self, ctx: Context, msg: Message) { // Only police the configured channel. if msg.channel_id != self.target_channel { return; } // Ignore bots (including ourselves) and system messages. if msg.author.bot || !matches!(msg.kind, MessageType::Regular) || self.self_id.load(Ordering::Relaxed) == msg.author.id.get() { return; } // Skip if this user is already being handled by an in-flight job. let Some(_guard) = InFlightGuard::try_acquire(&self.in_flight, msg.author.id) else { return; }; // 1) Timeout the offender. let until = Utc::now() + ChronoDuration::minutes(self.timeout_minutes); match self.timeout_user(&ctx, &msg, until).await { Ok(()) => println!( "Timed out {} ({}) until {}", msg.author.id, msg.author.name, until.to_rfc3339() ), Err(e) => eprintln!("Failed to timeout {}: {e}", msg.author.id), } // 2) Purge their messages from the last `purge_hours` in this channel. match self.purge_user_messages(&ctx, &msg).await { Ok(count) if count > 0 => { println!("Deleted {count} message(s) from {}", msg.author.id) } Ok(_) => println!("No messages to purge from {}", msg.author.id), Err(e) => eprintln!("Purge failed for {}: {e}", msg.author.id), } } } impl Handler { /// Timeout the user by disabling their ability to communicate until `until`. async fn timeout_user( &self, ctx: &Context, msg: &Message, until: chrono::DateTime, ) -> Result<(), serenity::Error> { let Some(guild_id) = msg.guild_id else { return Ok(()); // not in a guild; nothing to do }; let mut member = guild_id.member(&ctx.http, msg.author.id).await?; let until_ts = Timestamp::from_unix_timestamp(until.timestamp()) .expect("now + timeout minutes is always a valid unix timestamp"); member .disable_communication_until_datetime(&ctx.http, until_ts) .await } /// Delete every message written by `trigger.author` across every channel /// of the guild within the last `purge_hours`, including the trigger /// itself. async fn purge_user_messages( &self, ctx: &Context, trigger: &Message, ) -> Result { let Some(guild_id) = trigger.guild_id else { return Ok(0); // not in a guild; nothing to purge }; // Bulk deletion only works on messages younger than 14 days, so clamp // the window to 336 h even if configured larger. let cutoff = (Utc::now() - ChronoDuration::hours(self.purge_hours.min(336))).timestamp(); let channels = guild_id.channels(&ctx.http).await?; let mut total = 0; for (channel_id, _channel) in channels { // Read the channel's history; skip channels the bot can't see. let mut ids = match self .collect_user_messages(&ctx.http, channel_id, trigger.author.id, cutoff) .await { Ok(ids) => ids, Err(e) => { eprintln!("Skipping channel {channel_id}: {e}"); continue; } }; if channel_id == trigger.channel_id { ids.push(trigger.id); } ids.sort_unstable(); ids.dedup(); if ids.is_empty() { continue; } // The bulk endpoint caps each request at 100 messages; batch. for chunk in ids.chunks(BULK_DELETE_MAX) { channel_id.delete_messages(&ctx.http, chunk).await?; } total += ids.len(); } Ok(total) } /// Collect the IDs of every message by `author_id` in `channel_id` newer /// than `cutoff`, paging back through the channel's history. async fn collect_user_messages( &self, http: &Http, channel_id: ChannelId, author_id: UserId, cutoff: i64, ) -> Result, serenity::Error> { let mut to_delete: Vec = Vec::new(); let mut before = MessageId::new(i64::MAX as u64); let mut pages = 0; loop { pages += 1; let page = channel_id .messages(http, GetMessages::new().before(before).limit(PAGE_SIZE)) .await?; to_delete.extend(page.iter().filter(|m| { m.author.id == author_id && m.timestamp.unix_timestamp() >= cutoff }).map(|m| m.id)); let still_in_window = page .iter() .any(|m| m.timestamp.unix_timestamp() >= cutoff); // Reached the start of the channel, or the current page already // goes back further than the purge window: we're done. if page.len() < PAGE_SIZE as usize || !still_in_window || pages >= 500 { break; } before = page.last().expect("non-empty page").id; } Ok(to_delete) } } #[tokio::main] async fn main() { dotenvy::dotenv().ok(); // optional .env support tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); let token = env::var("DISCORD_TOKEN").expect("missing env var DISCORD_TOKEN (your bot's token)"); let channel_id: u64 = env::var("TARGET_CHANNEL_ID") .expect("missing env var TARGET_CHANNEL_ID (the channel to police)") .parse() .expect("TARGET_CHANNEL_ID must be a numeric snowflake (right-click channel -> Copy Channel ID)"); let timeout_minutes: i64 = env::var("TIMEOUT_MINUTES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60) .clamp(1, 40_320); // Discord's maximum timeout is 28 days. let purge_hours: i64 = env::var("PURGE_HOURS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(24); println!( "Policing channel {channel_id}: timeout = {timeout_minutes} min, purge window = {purge_hours} h" ); // Guild messages only: we never read message content, so the privileged // Message Content intent is not required. let intents = GatewayIntents::GUILD_MESSAGES; let handler = Handler { target_channel: ChannelId::new(channel_id), timeout_minutes, purge_hours, self_id: AtomicU64::new(0), in_flight: Mutex::new(HashSet::new()), }; let mut client = Client::builder(&token, intents) .event_handler(handler) .await .expect("Failed to create Discord client"); if let Err(e) = client.start().await { eprintln!("Client error: {e:?}"); std::process::exit(1); } }