use dashmap::DashMap; use itertools::Itertools; use log::{debug, error, info}; use serenity::all::{ActivityData, CreateEmbed, CreateMessage, TypingStartEvent}; use serenity::client::{Context, EventHandler}; use serenity::model::channel::Message; use serenity::model::colour::Colour; use serenity::model::event::ResumedEvent; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, UserId}; use std::cmp::Reverse; use std::collections::HashMap; use std::fmt::Display; use std::ops::RangeInclusive; use std::sync::Mutex; use std::time::{Duration, Instant}; use time::OffsetDateTime; use tokio::sync::mpsc; const DEFAULT_CONTEST_DURATION: Duration = Duration::from_secs(60); const ALLOWED_DURATION_RANGE: RangeInclusive = Duration::from_secs(10)..=Duration::from_secs(60 * 60); const PIN_ANNOUNCEMENT_THRESHOLD: Duration = Duration::from_secs(5 * 60); type Contests = DashMap>; #[derive(Default)] pub struct Handler { contests: Contests, ntfy_typing_last: Mutex>, } impl Handler { pub fn new() -> Self { Self::default() } } const NTFY_USER_ID: u64 = 313015629659635713; const NTFY_URL: &str = "https://ntfy.noisruker.de/up123456789TestTest"; const NTFY_TYPING_TIMEOUT: Duration = Duration::from_secs(60); #[serenity::async_trait] impl EventHandler for Handler { async fn typing_start(&self, _ctx: Context, event: TypingStartEvent) { if event .member .is_some_and(|m| m.user.id.get() == NTFY_USER_ID) && { self.ntfy_typing_last.lock() } .unwrap() .is_none_or(|i| i.elapsed() > NTFY_TYPING_TIMEOUT) { let client = reqwest::Client::new(); let res = client.post(NTFY_URL) .header("Title", "Ich höre zu ...") .header("Priority", "max") .header("Tags", "ear") .body("Du tippst gerade eine Nachricht auf Discord. Weiter so! Ich (wer auch immer ich sein mag) bin empfangsbereit.").send().await; match res { Ok(_) => info!("NTFY typing message sent"), Err(error) => error!("NTFY error: {error}"), } } *self.ntfy_typing_last.lock().unwrap() = Some(Instant::now()); } async fn message(&self, ctx: Context, msg: Message) { if msg.author.id.get() == NTFY_USER_ID { let client = reqwest::Client::new(); let res = client .post(NTFY_URL) .header("Title", "Ich habe deine Nachricht erhalten!") .header("Priority", "max") .header("Tags", "mailbox_with_mail") .body(format!("Du hast geschrieben: {}", msg.content)) .send() .await; match res { Ok(_) => info!("NTFY message message sent"), Err(error) => error!("NTFY error: {error}"), } *self.ntfy_typing_last.lock().unwrap() = None; } if let Some(contest) = self.contests.get(&msg.channel_id) { debug!( "Counting message {} (from {} in channel {})", msg.id, msg.author.tag(), msg.channel_id.get() ); contest.value().send(msg).await.unwrap(); return; } if msg.content.to_lowercase().contains("spam") { let duration = msg .content .split_ascii_whitespace() .filter_map(|w| w.parse().ok()) .map(Duration::from_secs) .find(|d| ALLOWED_DURATION_RANGE.contains(d)) .unwrap_or(DEFAULT_CONTEST_DURATION); info!( "User {} started a {} second contest in channel {}", msg.author.tag(), duration.as_secs(), msg.channel_id.get() ); match run_contest( ctx, msg.channel_id, duration, &self.contests, duration >= PIN_ANNOUNCEMENT_THRESHOLD, ) .await { Ok(contest) => debug!( "Contest in channel {} has ended with {} participant(s)", msg.channel_id.get(), contest.counts.len() ), Err(err) => error!( "Error while running contest in channel {}: {}", msg.channel_id.get(), err ), }; } } async fn ready(&self, ctx: Context, ready: Ready) { info!("Connected as {}", ready.user.tag()); ctx.set_activity(Some(ActivityData::listening("Spam"))); } async fn resume(&self, _ctx: Context, _: ResumedEvent) { info!("Resumed"); } } #[derive(Default, PartialEq, Eq)] struct SpamCount { messages: usize, characters: usize, } #[derive(Default)] struct Contest { counts: HashMap, } impl Contest { fn new() -> Self { Self::default() } fn count(&mut self, message: &Message) { let char_count = message.content.chars().count(); match self.counts.get_mut(&message.author.id) { None => { self.counts.insert( message.author.id, SpamCount { messages: 1, characters: char_count, }, ); } Some(count) => { count.messages += 1; count.characters += char_count; } }; } fn ranking_by(&self, fk: Fk, fd: Fd) -> String where Fk: Fn(&SpamCount) -> K, K: Ord, Fd: Fn(&SpamCount) -> D, D: Display, { let mut ranking = self.counts.iter().collect::>(); ranking.sort_unstable_by_key(|(_, c)| fk(c)); let mut result = String::new(); let mut cur_rank_num = 1; for (_, rank_group) in &ranking.into_iter().chunk_by(|elt| fk((elt).1)) { let mut group_size = 0; for (userid, count) in rank_group { result.push_str( format!("**{cur_rank_num}.:** <@{userid}> ({})\n", fd(count)).as_str(), ); group_size += 1; } cur_rank_num += group_size; } result } } async fn run_contest( ctx: Context, channel_id: ChannelId, duration: Duration, contests: &Contests, pin: bool, ) -> serenity::Result { let end_timestamp = (OffsetDateTime::now_utc() + duration).unix_timestamp(); // send announcement message let announcement = channel_id .send_message( &ctx.http, CreateMessage::new().add_embed( CreateEmbed::new() .title("Es wurde ein Spam-Wettbewerb gestartet!") .description(format!( "Wer am meisten spamt, gewinnt.\nEnde .", )) .colour(Colour::BLUE), ), ) .await?; if pin { announcement.pin(&ctx.http).await.ok(); } let mut counts = Contest::new(); { let (tx, mut rx) = mpsc::channel(8); contests.insert(channel_id, tx); tokio::select! { _ = tokio::time::sleep(duration) => {}, _ = async { while let Some(msg) = rx.recv().await { counts.count(&msg); } } => { unreachable!("mpsc receiver closed unexpectedly") }, } contests.remove(&channel_id); } if counts.counts.is_empty() { announcement.delete(&ctx.http).await?; } else { if pin { announcement.unpin(&ctx.http).await.ok(); } // send ranking message channel_id .send_message( &ctx.http, CreateMessage::new().add_embed( CreateEmbed::new() .title("Der Wettbewerb ist beendet!") .colour(Colour::DARK_GREEN) .field( "Ergebnisse (nach Nachrichten):", counts.ranking_by(|c| Reverse(c.messages), |c| c.messages), false, ) .field( "Ergebnisse (nach Zeichen):", counts.ranking_by(|c| Reverse(c.characters), |c| c.characters), false, ), ), ) .await?; } Ok(counts) }