use std::sync::Arc; use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; use cron::Schedule; use poise::serenity_prelude as serenity; use std::str::FromStr; use tokio::time::sleep; use tracing::{error, info}; use crate::copyparty::CopypartyClient; use crate::database::{self, DbPool}; use crate::music::{MusicState, SourceKind, TrackRequest}; /// Shared resources the cron scheduler needs. #[derive(Clone)] pub struct CronContext { pub pool: DbPool, pub http: Arc, pub copyparty: CopypartyClient, pub music: Arc, } /// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. /// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow). fn parse_cron(raw: &str) -> Result { let fields: Vec<&str> = raw.split_whitespace().collect(); let normalized = match fields.len() { 7 => raw.to_string(), 6 => format!("{} *", raw), // sec min hour dom month dow → +year 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year _ => raw.to_string(), // let the crate reject it }; Schedule::from_str(&normalized) } /// Tick interval for the scheduler loop. const TICK_SECONDS: u64 = 60; pub async fn check_and_execute_jobs(ctx: &CronContext) { let jobs = match database::list_active_cron_jobs(&ctx.pool).await { Ok(jobs) => jobs, Err(e) => { error!("Failed to load cron jobs: {e}"); return; } }; let now = Local::now(); for job in jobs { let schedule = match parse_cron(&job.cron_expression) { Ok(s) => s, Err(e) => { error!("Invalid cron expression '{}': {e}", job.cron_expression); continue; } }; // Find the next scheduled fire strictly after last_run. // - If last_run is None (freshly created or never run), start from the // last fire at/after the previous tick boundary so we still catch the // fire scheduled for "now" on the first scan without doubling up. // - If last_fire <= now, the job is due; we record last_run = last_fire // (not `now`) so a long execution delay can't skip a legitimately // pending later fire. // - Evaluate the cron expression in local time so "37 21 * * *" means // 21:37 in the server's timezone, not UTC. let reference: DateTime = job.last_run.map_or_else( || { // Step back a tick so the first scan after startup catches the fire // scheduled for the minute we wake up on. now - chrono::Duration::seconds(TICK_SECONDS as i64) }, |utc_last_run| Local.from_utc_datetime(&utc_last_run.naive_utc()), ); let due_fire = schedule.after(&reference).next().filter(|fire| *fire <= now); let Some(fire_time) = due_fire else { continue; }; info!("Executing cron job {} (fire {})", job.id, fire_time); match job.r#type.as_str() { "audio" => execute_audio_job(ctx, &job).await, "wiadomość" => execute_message_job(ctx, &job).await, _ => {} } // Record last_run so we never re-fire the same schedule instant on the // next tick. Kept outside the match so a failed/empty send still marks // the fire as consumed and doesn't retry on every tick. // Convert local fire_time to UTC for consistent DB storage. let fire_time_utc = fire_time.with_timezone(&Utc); if let Err(e) = database::update_cron_last_run(&ctx.pool, job.id, fire_time_utc).await { error!("Failed to update last_run for cron job {}: {e}", job.id); } } } async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) { let Some(ref msg_text) = job.message else { return; }; let channel_id = serenity::ChannelId::new(job.channel_id as u64); if let Err(e) = channel_id.say(&ctx.http, msg_text).await { error!("Cron job {} failed to send message: {e}", job.id); } else { info!("Cron job {} sent message to {}", job.id, job.channel_id); } } async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { let Some(ref audio_url) = job.audio_filename else { error!("Cron audio job {} has no audio_filename", job.id); return; }; let channel_id = serenity::ChannelId::new(job.channel_id as u64); // Authenticate only when the URL belongs to our Copyparty host; a cron // job may point at an arbitrary third-party URL and must never receive // the password. let url = ctx.copyparty.build_url_for(audio_url); info!( "Cron audio job {} playing: {}", job.id, crate::copyparty::redact_url(&url) ); let channel = match channel_id.to_channel(&ctx.http).await { Ok(serenity::Channel::Guild(ch)) => ch, other => { error!( "Cron audio job {}: channel {} is not a guild voice channel ({:?})", job.id, job.channel_id, other.map(|_| ()) ); return; } }; let request = TrackRequest { url: url.clone(), display_name: audio_url .rsplit('/') .next() .unwrap_or(audio_url) .to_string(), kind: SourceKind::Stream, compressed: false, reupload: None, requester: None, silent: true, }; if let Err(e) = ctx .music .play_now(channel.guild_id.get(), channel_id, None, request) .await { error!("Cron audio job {} playback failed: {e}", job.id); return; } info!( "Cron audio job {} playing '{}' (url: {}) in guild {}", job.id, audio_url, crate::copyparty::redact_url(&url), channel.guild_id ); } pub async fn cron_scheduler_loop(ctx: CronContext) { // Align first tick to the start of the next minute, then run every TICK_SECONDS. let now = Local::now(); let next_minute = (now + Duration::minutes(1)) .with_second(0) .and_then(|t| t.with_nanosecond(0)) .unwrap_or(now); let initial_delay = (next_minute - now).to_std().unwrap_or_default(); sleep(initial_delay).await; loop { check_and_execute_jobs(&ctx).await; sleep(std::time::Duration::from_secs(TICK_SECONDS)).await; } }