aboutsummaryrefslogtreecommitdiffstats
path: root/src/main.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-13 01:24:10 +0200
committersalmonik <salmonik@tuta.io>2026-08-13 01:24:10 +0200
commit710f616c05983f4b92944e05ad9dd485e1cdb121 (patch)
tree892b209032575246abc5e2a66eed856c95b28c3c /src/main.rs
downloadtimeoutinator-710f616c05983f4b92944e05ad9dd485e1cdb121.tar.gz
timeoutinator-710f616c05983f4b92944e05ad9dd485e1cdb121.zip
init
Diffstat (limited to '')
-rw-r--r--src/main.rs275
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
5use std::collections::HashSet;
6use std::env;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Mutex;
9
10use chrono::{Duration as ChronoDuration, Utc};
11use serenity::async_trait;
12use serenity::builder::GetMessages;
13use serenity::client::{Client, Context, EventHandler};
14use serenity::http::Http;
15use serenity::model::channel::{Message, MessageType};
16use serenity::model::gateway::Ready;
17use serenity::model::id::{ChannelId, MessageId, UserId};
18use serenity::model::Timestamp;
19use serenity::prelude::GatewayIntents;
20
21/// Discord's `GET /channels/{id}/messages` max page size.
22const PAGE_SIZE: u8 = 100;
23/// `DELETE /channels/{id}/messages` accepts at most 100 IDs per request.
24const BULK_DELETE_MAX: usize = 100;
25
26/// The bot's configuration and shared state.
27struct 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.
43struct InFlightGuard<'a>(&'a Mutex<HashSet<UserId>>, UserId);
44
45impl<'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
57impl 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]
66impl 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
113impl 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]
225async 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}