From 0c5c4a855122af2068bb0de579eda796cea50f6f Mon Sep 17 00:00:00 2001 From: nathan2slime Date: Wed, 29 Apr 2026 19:29:05 +0000 Subject: [PATCH 1/3] feat(bot): add kawaii point reminder DMs --- README.md | 1 + src/lib.rs | 2 + src/main.rs | 2 +- src/reminders.rs | 159 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 19 +++++- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/reminders.rs diff --git a/README.md b/README.md index d7e936a..98c93f0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Seris is not flashy. She focuses on correctness, stability, and clean execution. * Slash commands (Discord Interactions) * Clean permission boundaries * Predictable behavior +* Automatic DM reminders for the application owner at 12:00, 13:05, and 17:53 * Minimal Docker footprint * Fast startup, low memory usage diff --git a/src/lib.rs b/src/lib.rs index 07dc035..57eee05 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ pub mod config; #[cfg(feature = "bot")] pub mod embeds; #[cfg(feature = "bot")] +pub mod reminders; +#[cfg(feature = "bot")] pub mod services; #[cfg(test)] mod test_utils; diff --git a/src/main.rs b/src/main.rs index baa75d1..39369dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,7 +75,7 @@ async fn run() -> Result<(), Error> { .build(); let mut client = ClientBuilder::new(discord_token, intents) - .event_handler(seris::utils::Handler) + .event_handler(seris::utils::Handler::default()) .framework(framework) .await?; diff --git a/src/reminders.rs b/src/reminders.rs new file mode 100644 index 0000000..1af6262 --- /dev/null +++ b/src/reminders.rs @@ -0,0 +1,159 @@ +//! Scheduled DM reminders for time tracking. + +use chrono::{ + DateTime, Duration as ChronoDuration, Local, LocalResult, NaiveDateTime, NaiveTime, TimeZone, + Timelike, +}; +use log::{info, warn}; +use serenity::{ + all::{CreateMessage, User}, + http::Http, +}; +use std::{sync::Arc, time::Duration}; + +const KAWAII_REMINDER_GIFS: [&str; 3] = [ + "https://media.tenor.com/KDzt7A8t8WQAAAAC/anime-girl.gif", + "https://media.tenor.com/6kJd8PmdJxQAAAAC/anime-kawaii.gif", + "https://media.tenor.com/2roX3uxz_68AAAAC/anime-cute.gif", +]; + +fn reminder_times() -> [NaiveTime; 3] { + [ + NaiveTime::from_hms_opt(12, 0, 0).expect("valid reminder time"), + NaiveTime::from_hms_opt(13, 5, 0).expect("valid reminder time"), + NaiveTime::from_hms_opt(17, 53, 0).expect("valid reminder time"), + ] +} + +fn next_reminder_after_naive(now: NaiveDateTime) -> NaiveDateTime { + for time in reminder_times() { + let candidate = now.date().and_time(time); + if candidate > now { + return candidate; + } + } + + let tomorrow = now + .date() + .checked_add_signed(ChronoDuration::days(1)) + .expect("next day should be representable"); + + tomorrow.and_time(reminder_times()[0]) +} + +fn localize(naive: NaiveDateTime) -> DateTime { + match Local.from_local_datetime(&naive) { + LocalResult::Single(datetime) | LocalResult::Ambiguous(datetime, _) => datetime, + LocalResult::None => { + let fallback = naive + ChronoDuration::hours(1); + localize(fallback) + } + } +} + +fn next_reminder_after(now: DateTime) -> DateTime { + localize(next_reminder_after_naive(now.naive_local())) +} + +fn sleep_duration_until(target: DateTime, now: DateTime) -> Duration { + (target - now).to_std().unwrap_or(Duration::ZERO) +} + +async fn fetch_application_owner(http: &Http) -> serenity::Result { + let application = http.get_current_application_info().await?; + + if let Some(owner) = application.owner { + return Ok(owner); + } + + if let Some(team) = application.team { + return team.owner_user_id.to_user(http).await; + } + + Err(serenity::Error::Other("application owner is unavailable")) +} + +async fn send_point_reminder( + http: &Http, + owner: &User, + target: DateTime, +) -> serenity::Result<()> { + let gif_url = + KAWAII_REMINDER_GIFS[(target.time().hour() as usize) % KAWAII_REMINDER_GIFS.len()]; + + owner + .direct_message( + http, + CreateMessage::new().content(format!( + "Onii-chan, chegou a horinha de marcar o pontinho das {} kawaii~\nNao esquece, ta bom? Eu vou ficar felizinha quando voce marcar certinho.\n{}", + target.format("%H:%M"), + gif_url, + )), + ) + .await?; + + Ok(()) +} + +pub async fn run_point_reminder_loop(http: Arc) { + let owner = match fetch_application_owner(http.as_ref()).await { + Ok(owner) => owner, + Err(err) => { + warn!("point reminders disabled: could not resolve application owner: {err}"); + return; + } + }; + + info!("point reminders enabled for application owner {}", owner.id); + + loop { + let now = Local::now(); + let target = next_reminder_after(now); + let sleep_for = sleep_duration_until(target, now); + + info!( + "next point reminder scheduled for {}", + target.format("%Y-%m-%d %H:%M:%S") + ); + + tokio::time::sleep(sleep_for).await; + + if let Err(err) = send_point_reminder(http.as_ref(), &owner, target).await { + warn!("failed to send point reminder DM: {err}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::next_reminder_after_naive; + use chrono::{NaiveDate, NaiveDateTime}; + + fn datetime(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(year, month, day) + .expect("valid date") + .and_hms_opt(hour, minute, 0) + .expect("valid time") + } + + #[test] + fn picks_first_remaining_time_today() { + let now = datetime(2026, 4, 29, 11, 30); + + assert_eq!(next_reminder_after_naive(now), datetime(2026, 4, 29, 12, 0)); + } + + #[test] + fn skips_current_time_and_moves_to_next_slot() { + let now = datetime(2026, 4, 29, 12, 0); + + assert_eq!(next_reminder_after_naive(now), datetime(2026, 4, 29, 13, 5)); + } + + #[test] + fn rolls_over_to_next_day_after_last_slot() { + let now = datetime(2026, 4, 29, 18, 0); + + assert_eq!(next_reminder_after_naive(now), datetime(2026, 4, 30, 12, 0)); + } +} diff --git a/src/utils.rs b/src/utils.rs index c80dfdf..ae63f70 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,13 +5,28 @@ use serenity::{ all::{Context, EventHandler, Ready}, async_trait, }; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; /// Event handler used by the bot client. -pub struct Handler; +#[derive(Default)] +pub struct Handler { + reminder_task_started: Arc, +} #[async_trait] impl EventHandler for Handler { - async fn ready(&self, _: Context, ready: Ready) { + async fn ready(&self, ctx: Context, ready: Ready) { info!("{} is connected!", ready.user.name); + + if self + .reminder_task_started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + tokio::spawn(crate::reminders::run_point_reminder_loop(ctx.http.clone())); + } } } From bb44e469e383662efbd7914256dfc68b4a8a68a5 Mon Sep 17 00:00:00 2001 From: nathan2slime Date: Wed, 29 Apr 2026 20:21:19 +0000 Subject: [PATCH 2/3] chore(bot): refresh point reminder gifs --- src/reminders.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/reminders.rs b/src/reminders.rs index 1af6262..8956a45 100644 --- a/src/reminders.rs +++ b/src/reminders.rs @@ -11,10 +11,12 @@ use serenity::{ }; use std::{sync::Arc, time::Duration}; -const KAWAII_REMINDER_GIFS: [&str; 3] = [ - "https://media.tenor.com/KDzt7A8t8WQAAAAC/anime-girl.gif", - "https://media.tenor.com/6kJd8PmdJxQAAAAC/anime-kawaii.gif", - "https://media.tenor.com/2roX3uxz_68AAAAC/anime-cute.gif", +const KAWAII_REMINDER_GIFS: [&str; 5] = [ + "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExc3hvYTJpZXFrYTRmYnp4cWhjMTdoNzZhd2tkN3V5bmF0cXhxdDhnYSZlcD12MV9naWZzX3NlYXJjaCZjdD1n/CiZ9e5IUPqeVFzc8Mp/giphy.gif", + "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYThzamt5dmN1cmhsbXlydDR4dzQyb2VrMXhuZTE4c28yeGUyejJmciZlcD12MV9naWZzX3JlbGF0ZWQmY3Q9Zw/o3OWvKmIRpEkJkkNFs/giphy.gif", + "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExYThzamt5dmN1cmhsbXlydDR4dzQyb2VrMXhuZTE4c28yeGUyejJmciZlcD12MV9naWZzX3JlbGF0ZWQmY3Q9Zw/v2hDi1Se8UldZWWL4B/giphy.gif", + "https://media.giphy.com/media/v1.Y2lkPWVjZjA1ZTQ3Z3hieGh3ZGJ6aG1nZWMwZmFvMXpxanBwZzJrcG05ZHQweWZ0bmV6ciZlcD12MV9naWZzX3JlbGF0ZWQmY3Q9Zw/DObgk0NPQh57OBQmzX/giphy.gif", + "https://media.giphy.com/media/v1.Y2lkPWVjZjA1ZTQ3Z3hieGh3ZGJ6aG1nZWMwZmFvMXpxanBwZzJrcG05ZHQweWZ0bmV6ciZlcD12MV9naWZzX3JlbGF0ZWQmY3Q9Zw/dKBES1ypGwZdyFQBQ7/giphy.gif", ]; fn reminder_times() -> [NaiveTime; 3] { From 30e932949f4e0aa868ae9113a6d0c8a3ac9e14aa Mon Sep 17 00:00:00 2001 From: nathan2slime Date: Wed, 29 Apr 2026 21:45:13 +0000 Subject: [PATCH 3/3] feat(bot): randomize point reminder gif --- Cargo.lock | 1 + Cargo.toml | 1 + src/reminders.rs | 22 ++++++++++++---------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2db9e13..efcd6bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1832,6 +1832,7 @@ dependencies = [ "log", "poise", "r2d2", + "rand", "reqwest", "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index a65c82c..986c5bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ dashmap = "6.1.0" env_logger = "0.11.6" log = "0.4.22" poise = "0.6.1" +rand = "0.8" reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } r2d2 = "0.8" rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/src/reminders.rs b/src/reminders.rs index 8956a45..c498ce2 100644 --- a/src/reminders.rs +++ b/src/reminders.rs @@ -2,11 +2,11 @@ use chrono::{ DateTime, Duration as ChronoDuration, Local, LocalResult, NaiveDateTime, NaiveTime, TimeZone, - Timelike, }; use log::{info, warn}; +use rand::seq::SliceRandom; use serenity::{ - all::{CreateMessage, User}, + all::{CreateEmbed, CreateMessage, User}, http::Http, }; use std::{sync::Arc, time::Duration}; @@ -75,22 +75,24 @@ async fn fetch_application_owner(http: &Http) -> serenity::Result { Err(serenity::Error::Other("application owner is unavailable")) } -async fn send_point_reminder( +pub async fn send_point_reminder( http: &Http, owner: &User, target: DateTime, ) -> serenity::Result<()> { - let gif_url = - KAWAII_REMINDER_GIFS[(target.time().hour() as usize) % KAWAII_REMINDER_GIFS.len()]; + let gif_url = KAWAII_REMINDER_GIFS + .choose(&mut rand::thread_rng()) + .expect("reminder GIF list should not be empty"); owner .direct_message( http, - CreateMessage::new().content(format!( - "Onii-chan, chegou a horinha de marcar o pontinho das {} kawaii~\nNao esquece, ta bom? Eu vou ficar felizinha quando voce marcar certinho.\n{}", - target.format("%H:%M"), - gif_url, - )), + CreateMessage::new() + .content(format!( + "Onii-chan, chegou a horinha de bater o ponto das {}.\nNão esquece, tá bom? Vou ficar felizinha quando você marcar certinho.", + target.format("%H:%M"), + )) + .embed(CreateEmbed::new().image(*gif_url)), ) .await?;