aboutsummaryrefslogtreecommitdiffstats
path: root/src/tasks/mod.rs
diff options
context:
space:
mode:
authorsalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
committersalmonik <raisersoft@proton.me>2026-07-08 02:13:35 +0200
commitcd8ed8bc5793985df2c1692072a932ceee517b95 (patch)
tree42d18a4140259b5c68b4dd05b7fcb398f78b8d7f /src/tasks/mod.rs
parentMerge pull request 'Voice related fixes, alongside simplifying assistant' (#5... (diff)
downloadsenator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.tar.gz
senator-sperma-cd8ed8bc5793985df2c1692072a932ceee517b95.zip
OPEN THE RUST GATES
Signed-off-by: salmonik <raisersoft@proton.me>
Diffstat (limited to '')
-rw-r--r--src/tasks/mod.rs172
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 @@
1use std::sync::Arc;
2
3use chrono::{Duration, Timelike, Utc};
4use cron::Schedule;
5use poise::serenity_prelude as serenity;
6use std::str::FromStr;
7use tokio::time::sleep;
8use tracing::{error, info};
9
10use crate::copyparty::CopypartyClient;
11use crate::database::{self, DbPool};
12use crate::voice::VoiceManager;
13
14/// Shared resources the cron scheduler needs.
15#[derive(Clone)]
16pub 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).
25fn 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.
38const WINDOW_SECONDS: i64 = 61;
39/// Tick interval for the scheduler loop.
40const TICK_SECONDS: u64 = 60;
41
42pub 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
86async 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
98async 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
158pub 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}