diff options
Diffstat (limited to 'src/tasks')
| -rw-r--r-- | src/tasks/mod.rs | 172 |
1 files changed, 172 insertions, 0 deletions
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs new file mode 100644 index 0000000..f3f8811 --- /dev/null +++ b/src/tasks/mod.rs | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | use std::sync::Arc; | ||
| 2 | |||
| 3 | use chrono::{Duration, Timelike, Utc}; | ||
| 4 | use cron::Schedule; | ||
| 5 | use poise::serenity_prelude as serenity; | ||
| 6 | use std::str::FromStr; | ||
| 7 | use tokio::time::sleep; | ||
| 8 | use tracing::{error, info}; | ||
| 9 | |||
| 10 | use crate::copyparty::CopypartyClient; | ||
| 11 | use crate::database::{self, DbPool}; | ||
| 12 | use crate::voice::VoiceManager; | ||
| 13 | |||
| 14 | /// Shared resources the cron scheduler needs. | ||
| 15 | #[derive(Clone)] | ||
| 16 | pub struct CronContext { | ||
| 17 | pub pool: DbPool, | ||
| 18 | pub http: Arc<serenity::Http>, | ||
| 19 | pub voice_manager: VoiceManager, | ||
| 20 | pub copyparty: CopypartyClient, | ||
| 21 | } | ||
| 22 | |||
| 23 | /// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. | ||
| 24 | /// Supports: 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow). | ||
| 25 | fn parse_cron(raw: &str) -> Result<Schedule, cron::error::Error> { | ||
| 26 | let fields: Vec<&str> = raw.split_whitespace().collect(); | ||
| 27 | let normalized = match fields.len() { | ||
| 28 | 7 => raw.to_string(), | ||
| 29 | 6 => format!("{} *", raw), // sec min hour dom month dow → +year | ||
| 30 | 5 => format!("0 {} *", raw), // min hour dom month dow → +sec +year | ||
| 31 | _ => raw.to_string(), // let the crate reject it | ||
| 32 | }; | ||
| 33 | Schedule::from_str(&normalized) | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Check window: look for cron fires in the last WINDOW_SECONDS. | ||
| 37 | /// 61s gives 1s overlap with 60s tick to avoid missing exact-boundary fires. | ||
| 38 | const WINDOW_SECONDS: i64 = 61; | ||
| 39 | /// Tick interval for the scheduler loop. | ||
| 40 | const TICK_SECONDS: u64 = 60; | ||
| 41 | |||
| 42 | pub async fn check_and_execute_jobs(ctx: &CronContext) { | ||
| 43 | let jobs = match database::list_active_cron_jobs(&ctx.pool).await { | ||
| 44 | Ok(jobs) => jobs, | ||
| 45 | Err(e) => { | ||
| 46 | error!("Failed to load cron jobs: {e}"); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | }; | ||
| 50 | |||
| 51 | let now = Utc::now(); | ||
| 52 | let window_start = now - Duration::seconds(WINDOW_SECONDS); | ||
| 53 | |||
| 54 | for job in jobs { | ||
| 55 | let schedule = match parse_cron(&job.cron_expression) { | ||
| 56 | Ok(s) => s, | ||
| 57 | Err(e) => { | ||
| 58 | error!("Invalid cron expression '{}': {e}", job.cron_expression); | ||
| 59 | continue; | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | |||
| 63 | // Check if any fire time fell within the last WINDOW_SECONDS. | ||
| 64 | let mut has_fired = false; | ||
| 65 | for next_fire in schedule.after(&window_start) { | ||
| 66 | if next_fire > now { | ||
| 67 | break; | ||
| 68 | } | ||
| 69 | has_fired = true; | ||
| 70 | break; // one fire per window is enough | ||
| 71 | } | ||
| 72 | |||
| 73 | if !has_fired { | ||
| 74 | continue; | ||
| 75 | } | ||
| 76 | |||
| 77 | info!("Executing cron job {}", job.id); | ||
| 78 | match job.r#type.as_str() { | ||
| 79 | "audio" => execute_audio_job(ctx, &job).await, | ||
| 80 | "wiadomość" => execute_message_job(ctx, &job).await, | ||
| 81 | _ => {} | ||
| 82 | } | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob) { | ||
| 87 | let Some(ref msg_text) = job.message else { | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); | ||
| 91 | if let Err(e) = channel_id.say(&ctx.http, msg_text).await { | ||
| 92 | error!("Cron job {} failed to send message: {e}", job.id); | ||
| 93 | } else { | ||
| 94 | info!("Cron job {} sent message to {}", job.id, job.channel_id); | ||
| 95 | } | ||
| 96 | } | ||
| 97 | |||
| 98 | async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { | ||
| 99 | let Some(ref audio_path) = job.audio_filename else { | ||
| 100 | error!("Cron audio job {} has no audio_filename", job.id); | ||
| 101 | return; | ||
| 102 | }; | ||
| 103 | |||
| 104 | let channel_id = serenity::ChannelId::new(job.channel_id as u64); | ||
| 105 | |||
| 106 | // Resolve guild + build URL concurrently. | ||
| 107 | let url = ctx.copyparty.build_url(audio_path); | ||
| 108 | info!("Cron audio job {} streaming via ffmpeg: {url}", job.id); | ||
| 109 | let guild_channel_fut = channel_id.to_channel(&ctx.http); | ||
| 110 | let input_fut = crate::voice::ffmpeg_stream_input(&url); | ||
| 111 | |||
| 112 | let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut); | ||
| 113 | |||
| 114 | let guild_id = match channel_result { | ||
| 115 | Ok(serenity::Channel::Guild(ch)) => ch.guild_id, | ||
| 116 | other => { | ||
| 117 | error!( | ||
| 118 | "Cron audio job {}: channel {} is not a guild voice channel ({:?})", | ||
| 119 | job.id, | ||
| 120 | job.channel_id, | ||
| 121 | other.map(|_| ()) | ||
| 122 | ); | ||
| 123 | return; | ||
| 124 | } | ||
| 125 | }; | ||
| 126 | |||
| 127 | let input = match input_result { | ||
| 128 | Ok(i) => i, | ||
| 129 | Err(e) => { | ||
| 130 | error!( | ||
| 131 | "Cron audio job {} ffmpeg stream failed ({url}): {e}", | ||
| 132 | job.id | ||
| 133 | ); | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | }; | ||
| 137 | |||
| 138 | // Connect to voice and play. | ||
| 139 | let call = match ctx.voice_manager.connect(guild_id, channel_id).await { | ||
| 140 | Ok(c) => c, | ||
| 141 | Err(e) => { | ||
| 142 | error!("Cron audio job {} voice connect failed: {e}", job.id); | ||
| 143 | return; | ||
| 144 | } | ||
| 145 | }; | ||
| 146 | |||
| 147 | { | ||
| 148 | let mut call_lock = call.lock().await; | ||
| 149 | call_lock.play_input(input); | ||
| 150 | } | ||
| 151 | |||
| 152 | info!( | ||
| 153 | "Cron audio job {} playing '{}' (url: {url}) in guild {}", | ||
| 154 | job.id, audio_path, guild_id | ||
| 155 | ); | ||
| 156 | } | ||
| 157 | |||
| 158 | pub async fn cron_scheduler_loop(ctx: CronContext) { | ||
| 159 | // Align first tick to the start of the next minute, then run every TICK_SECONDS. | ||
| 160 | let now = Utc::now(); | ||
| 161 | let next_minute = (now + Duration::minutes(1)) | ||
| 162 | .with_second(0) | ||
| 163 | .and_then(|t| t.with_nanosecond(0)) | ||
| 164 | .unwrap_or(now); | ||
| 165 | let initial_delay = (next_minute - now).to_std().unwrap_or_default(); | ||
| 166 | sleep(initial_delay).await; | ||
| 167 | |||
| 168 | loop { | ||
| 169 | check_and_execute_jobs(&ctx).await; | ||
| 170 | sleep(std::time::Duration::from_secs(TICK_SECONDS)).await; | ||
| 171 | } | ||
| 172 | } | ||
