diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-13 01:24:10 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-13 01:24:10 +0200 |
| commit | 710f616c05983f4b92944e05ad9dd485e1cdb121 (patch) | |
| tree | 892b209032575246abc5e2a66eed856c95b28c3c /src | |
| download | timeoutinator-710f616c05983f4b92944e05ad9dd485e1cdb121.tar.gz timeoutinator-710f616c05983f4b92944e05ad9dd485e1cdb121.zip | |
init
Diffstat (limited to '')
| -rw-r--r-- | src/main.rs | 275 |
1 files changed, 275 insertions, 0 deletions
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 @@ | |||
| 1 | //! Timeoutinator — a Discord bot that instantly timeouts anyone who posts | ||
| 2 | //! in a configured channel and purges that user's messages from the last | ||
| 3 | //! 24 hours. | ||
| 4 | |||
| 5 | use std::collections::HashSet; | ||
| 6 | use std::env; | ||
| 7 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 8 | use std::sync::Mutex; | ||
| 9 | |||
| 10 | use chrono::{Duration as ChronoDuration, Utc}; | ||
| 11 | use serenity::async_trait; | ||
| 12 | use serenity::builder::GetMessages; | ||
| 13 | use serenity::client::{Client, Context, EventHandler}; | ||
| 14 | use serenity::http::Http; | ||
| 15 | use serenity::model::channel::{Message, MessageType}; | ||
| 16 | use serenity::model::gateway::Ready; | ||
| 17 | use serenity::model::id::{ChannelId, MessageId, UserId}; | ||
| 18 | use serenity::model::Timestamp; | ||
| 19 | use serenity::prelude::GatewayIntents; | ||
| 20 | |||
| 21 | /// Discord's `GET /channels/{id}/messages` max page size. | ||
| 22 | const PAGE_SIZE: u8 = 100; | ||
| 23 | /// `DELETE /channels/{id}/messages` accepts at most 100 IDs per request. | ||
| 24 | const BULK_DELETE_MAX: usize = 100; | ||
| 25 | |||
| 26 | /// The bot's configuration and shared state. | ||
| 27 | struct Handler { | ||
| 28 | /// Channel whose messages trigger the timeout + purge. | ||
| 29 | target_channel: ChannelId, | ||
| 30 | /// How long the timeout lasts (minutes). | ||
| 31 | timeout_minutes: i64, | ||
| 32 | /// Purge window for the offender's messages (hours). | ||
| 33 | purge_hours: i64, | ||
| 34 | /// The bot's own user ID, captured on `Ready` (the cache feature is | ||
| 35 | /// disabled, so we track it manually). | ||
| 36 | self_id: AtomicU64, | ||
| 37 | /// Users currently being processed, so a rapid spam click doesn't stack | ||
| 38 | /// concurrent purge/timeout jobs for the same user. | ||
| 39 | in_flight: Mutex<HashSet<UserId>>, | ||
| 40 | } | ||
| 41 | |||
| 42 | /// RAII guard that frees a user's in-flight slot when the handler is done. | ||
| 43 | struct InFlightGuard<'a>(&'a Mutex<HashSet<UserId>>, UserId); | ||
| 44 | |||
| 45 | impl<'a> InFlightGuard<'a> { | ||
| 46 | fn try_acquire(lock: &'a Mutex<HashSet<UserId>>, user: UserId) -> Option<Self> { | ||
| 47 | let mut set = lock.lock().ok()?; | ||
| 48 | if set.contains(&user) { | ||
| 49 | None | ||
| 50 | } else { | ||
| 51 | set.insert(user); | ||
| 52 | Some(Self(lock, user)) | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | impl Drop for InFlightGuard<'_> { | ||
| 58 | fn drop(&mut self) { | ||
| 59 | if let Ok(mut set) = self.0.lock() { | ||
| 60 | set.remove(&self.1); | ||
| 61 | } | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | #[async_trait] | ||
| 66 | impl EventHandler for Handler { | ||
| 67 | async fn ready(&self, _ctx: Context, ready: Ready) { | ||
| 68 | self.self_id.store(ready.user.id.get(), Ordering::Relaxed); | ||
| 69 | println!("Logged in as {}", ready.user.name); | ||
| 70 | } | ||
| 71 | |||
| 72 | async fn message(&self, ctx: Context, msg: Message) { | ||
| 73 | // Only police the configured channel. | ||
| 74 | if msg.channel_id != self.target_channel { | ||
| 75 | return; | ||
| 76 | } | ||
| 77 | // Ignore bots (including ourselves) and system messages. | ||
| 78 | if msg.author.bot | ||
| 79 | || !matches!(msg.kind, MessageType::Regular) | ||
| 80 | || self.self_id.load(Ordering::Relaxed) == msg.author.id.get() | ||
| 81 | { | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | |||
| 85 | // Skip if this user is already being handled by an in-flight job. | ||
| 86 | let Some(_guard) = InFlightGuard::try_acquire(&self.in_flight, msg.author.id) else { | ||
| 87 | return; | ||
| 88 | }; | ||
| 89 | |||
| 90 | // 1) Timeout the offender. | ||
| 91 | let until = Utc::now() + ChronoDuration::minutes(self.timeout_minutes); | ||
| 92 | match self.timeout_user(&ctx, &msg, until).await { | ||
| 93 | Ok(()) => println!( | ||
| 94 | "Timed out {} ({}) until {}", | ||
| 95 | msg.author.id, | ||
| 96 | msg.author.name, | ||
| 97 | until.to_rfc3339() | ||
| 98 | ), | ||
| 99 | Err(e) => eprintln!("Failed to timeout {}: {e}", msg.author.id), | ||
| 100 | } | ||
| 101 | |||
| 102 | // 2) Purge their messages from the last `purge_hours` in this channel. | ||
| 103 | match self.purge_user_messages(&ctx, &msg).await { | ||
| 104 | Ok(count) if count > 0 => { | ||
| 105 | println!("Deleted {count} message(s) from {}", msg.author.id) | ||
| 106 | } | ||
| 107 | Ok(_) => println!("No messages to purge from {}", msg.author.id), | ||
| 108 | Err(e) => eprintln!("Purge failed for {}: {e}", msg.author.id), | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | impl Handler { | ||
| 114 | /// Timeout the user by disabling their ability to communicate until `until`. | ||
| 115 | async fn timeout_user( | ||
| 116 | &self, | ||
| 117 | ctx: &Context, | ||
| 118 | msg: &Message, | ||
| 119 | until: chrono::DateTime<Utc>, | ||
| 120 | ) -> Result<(), serenity::Error> { | ||
| 121 | let Some(guild_id) = msg.guild_id else { | ||
| 122 | return Ok(()); // not in a guild; nothing to do | ||
| 123 | }; | ||
| 124 | |||
| 125 | let mut member = guild_id.member(&ctx.http, msg.author.id).await?; | ||
| 126 | let until_ts = Timestamp::from_unix_timestamp(until.timestamp()) | ||
| 127 | .expect("now + timeout minutes is always a valid unix timestamp"); | ||
| 128 | |||
| 129 | member | ||
| 130 | .disable_communication_until_datetime(&ctx.http, until_ts) | ||
| 131 | .await | ||
| 132 | } | ||
| 133 | |||
| 134 | /// Delete every message written by `trigger.author` across every channel | ||
| 135 | /// of the guild within the last `purge_hours`, including the trigger | ||
| 136 | /// itself. | ||
| 137 | async fn purge_user_messages( | ||
| 138 | &self, | ||
| 139 | ctx: &Context, | ||
| 140 | trigger: &Message, | ||
| 141 | ) -> Result<usize, serenity::Error> { | ||
| 142 | let Some(guild_id) = trigger.guild_id else { | ||
| 143 | return Ok(0); // not in a guild; nothing to purge | ||
| 144 | }; | ||
| 145 | |||
| 146 | // Bulk deletion only works on messages younger than 14 days, so clamp | ||
| 147 | // the window to 336 h even if configured larger. | ||
| 148 | let cutoff = (Utc::now() - ChronoDuration::hours(self.purge_hours.min(336))).timestamp(); | ||
| 149 | |||
| 150 | let channels = guild_id.channels(&ctx.http).await?; | ||
| 151 | |||
| 152 | let mut total = 0; | ||
| 153 | for (channel_id, _channel) in channels { | ||
| 154 | // Read the channel's history; skip channels the bot can't see. | ||
| 155 | let mut ids = match self | ||
| 156 | .collect_user_messages(&ctx.http, channel_id, trigger.author.id, cutoff) | ||
| 157 | .await | ||
| 158 | { | ||
| 159 | Ok(ids) => ids, | ||
| 160 | Err(e) => { | ||
| 161 | eprintln!("Skipping channel {channel_id}: {e}"); | ||
| 162 | continue; | ||
| 163 | } | ||
| 164 | }; | ||
| 165 | |||
| 166 | if channel_id == trigger.channel_id { | ||
| 167 | ids.push(trigger.id); | ||
| 168 | } | ||
| 169 | ids.sort_unstable(); | ||
| 170 | ids.dedup(); | ||
| 171 | |||
| 172 | if ids.is_empty() { | ||
| 173 | continue; | ||
| 174 | } | ||
| 175 | |||
| 176 | // The bulk endpoint caps each request at 100 messages; batch. | ||
| 177 | for chunk in ids.chunks(BULK_DELETE_MAX) { | ||
| 178 | channel_id.delete_messages(&ctx.http, chunk).await?; | ||
| 179 | } | ||
| 180 | total += ids.len(); | ||
| 181 | } | ||
| 182 | Ok(total) | ||
| 183 | } | ||
| 184 | |||
| 185 | /// Collect the IDs of every message by `author_id` in `channel_id` newer | ||
| 186 | /// than `cutoff`, paging back through the channel's history. | ||
| 187 | async fn collect_user_messages( | ||
| 188 | &self, | ||
| 189 | http: &Http, | ||
| 190 | channel_id: ChannelId, | ||
| 191 | author_id: UserId, | ||
| 192 | cutoff: i64, | ||
| 193 | ) -> Result<Vec<MessageId>, serenity::Error> { | ||
| 194 | let mut to_delete: Vec<MessageId> = Vec::new(); | ||
| 195 | let mut before = MessageId::new(i64::MAX as u64); | ||
| 196 | let mut pages = 0; | ||
| 197 | |||
| 198 | loop { | ||
| 199 | pages += 1; | ||
| 200 | let page = channel_id | ||
| 201 | .messages(http, GetMessages::new().before(before).limit(PAGE_SIZE)) | ||
| 202 | .await?; | ||
| 203 | |||
| 204 | to_delete.extend(page.iter().filter(|m| { | ||
| 205 | m.author.id == author_id && m.timestamp.unix_timestamp() >= cutoff | ||
| 206 | }).map(|m| m.id)); | ||
| 207 | |||
| 208 | let still_in_window = page | ||
| 209 | .iter() | ||
| 210 | .any(|m| m.timestamp.unix_timestamp() >= cutoff); | ||
| 211 | |||
| 212 | // Reached the start of the channel, or the current page already | ||
| 213 | // goes back further than the purge window: we're done. | ||
| 214 | if page.len() < PAGE_SIZE as usize || !still_in_window || pages >= 500 { | ||
| 215 | break; | ||
| 216 | } | ||
| 217 | before = page.last().expect("non-empty page").id; | ||
| 218 | } | ||
| 219 | |||
| 220 | Ok(to_delete) | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | #[tokio::main] | ||
| 225 | async fn main() { | ||
| 226 | dotenvy::dotenv().ok(); // optional .env support | ||
| 227 | tracing_subscriber::fmt() | ||
| 228 | .with_max_level(tracing::Level::INFO) | ||
| 229 | .init(); | ||
| 230 | |||
| 231 | let token = | ||
| 232 | env::var("DISCORD_TOKEN").expect("missing env var DISCORD_TOKEN (your bot's token)"); | ||
| 233 | |||
| 234 | let channel_id: u64 = env::var("TARGET_CHANNEL_ID") | ||
| 235 | .expect("missing env var TARGET_CHANNEL_ID (the channel to police)") | ||
| 236 | .parse() | ||
| 237 | .expect("TARGET_CHANNEL_ID must be a numeric snowflake (right-click channel -> Copy Channel ID)"); | ||
| 238 | |||
| 239 | let timeout_minutes: i64 = env::var("TIMEOUT_MINUTES") | ||
| 240 | .ok() | ||
| 241 | .and_then(|v| v.parse().ok()) | ||
| 242 | .unwrap_or(60) | ||
| 243 | .clamp(1, 40_320); // Discord's maximum timeout is 28 days. | ||
| 244 | |||
| 245 | let purge_hours: i64 = env::var("PURGE_HOURS") | ||
| 246 | .ok() | ||
| 247 | .and_then(|v| v.parse().ok()) | ||
| 248 | .unwrap_or(24); | ||
| 249 | |||
| 250 | println!( | ||
| 251 | "Policing channel {channel_id}: timeout = {timeout_minutes} min, purge window = {purge_hours} h" | ||
| 252 | ); | ||
| 253 | |||
| 254 | // Guild messages only: we never read message content, so the privileged | ||
| 255 | // Message Content intent is not required. | ||
| 256 | let intents = GatewayIntents::GUILD_MESSAGES; | ||
| 257 | |||
| 258 | let handler = Handler { | ||
| 259 | target_channel: ChannelId::new(channel_id), | ||
| 260 | timeout_minutes, | ||
| 261 | purge_hours, | ||
| 262 | self_id: AtomicU64::new(0), | ||
| 263 | in_flight: Mutex::new(HashSet::new()), | ||
| 264 | }; | ||
| 265 | |||
| 266 | let mut client = Client::builder(&token, intents) | ||
| 267 | .event_handler(handler) | ||
| 268 | .await | ||
| 269 | .expect("Failed to create Discord client"); | ||
| 270 | |||
| 271 | if let Err(e) = client.start().await { | ||
| 272 | eprintln!("Client error: {e:?}"); | ||
| 273 | std::process::exit(1); | ||
| 274 | } | ||
| 275 | } | ||
