From 710f616c05983f4b92944e05ad9dd485e1cdb121 Mon Sep 17 00:00:00 2001 From: salmonik Date: Thu, 13 Aug 2026 01:24:10 +0200 Subject: init --- src/main.rs | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 src/main.rs (limited to 'src') diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..81cae03 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,275 @@ +//! 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); + } +} -- cgit v1.2.3