Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Seris is not flashy. She focuses on correctness, stability, and clean execution.
* Plugin-based command registry
* SQLite-backed persistence with a connection pool
* Benchmark helpers for critical paths
* Automatic DM reminders for the application owner at 12:00, 13:05, and 17:53
* Minimal Docker footprint
* Fast startup, low memory usage
* Standard runtime logs for bot lifecycle and errors
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub mod epic;
#[cfg(feature = "bot")]
pub mod plugins;
#[cfg(feature = "bot")]
pub mod reminders;
#[cfg(feature = "bot")]
pub mod services;
#[cfg(test)]
mod test_utils;
Expand Down
163 changes: 163 additions & 0 deletions src/reminders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! Scheduled DM reminders for time tracking.

use chrono::{
DateTime, Duration as ChronoDuration, Local, LocalResult, NaiveDateTime, NaiveTime, TimeZone,
};
use log::{info, warn};
use rand::seq::SliceRandom;
use serenity::{
all::{CreateEmbed, CreateMessage, User},
http::Http,
};
use std::{sync::Arc, time::Duration};

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] {
[
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<Local> {
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<Local>) -> DateTime<Local> {
localize(next_reminder_after_naive(now.naive_local()))
}

fn sleep_duration_until(target: DateTime<Local>, now: DateTime<Local>) -> Duration {
(target - now).to_std().unwrap_or(Duration::ZERO)
}

async fn fetch_application_owner(http: &Http) -> serenity::Result<User> {
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"))
}

pub async fn send_point_reminder(
http: &Http,
owner: &User,
target: DateTime<Local>,
) -> serenity::Result<()> {
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 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?;

Ok(())
}

pub async fn run_point_reminder_loop(http: Arc<Http>) {
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));
}
}
20 changes: 17 additions & 3 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,35 @@ use serenity::{
all::{Context, EventHandler, Ready, ResumedEvent},
async_trait,
};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};

/// Event handler used by the bot client.
#[derive(Default)]
pub struct Handler;
pub struct Handler {
reminder_task_started: Arc<AtomicBool>,
}

impl Handler {
pub fn new() -> Self {
Self
Self::default()
}
}

#[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()));
}
}

async fn resume(&self, _: Context, _: ResumedEvent) {
Expand Down
Loading