//! Recurring bot runs. Chat or the computer panel can create them; a tick //! loop turns due rows into ordinary queued runs. use std::str::FromStr; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::{DateTime, Utc}; use chrono_tz::Tz; use cron::Schedule; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::FromRow; use uuid::Uuid; use crate::db::Actor; use crate::state::AppState; #[derive(Debug, Clone, Serialize, FromRow)] #[serde(rename_all = "camelCase")] pub struct ScheduleRow { pub id: String, pub bot_id: String, pub thread_id: Option, pub name: String, pub cron: String, pub timezone: String, pub instructions: String, pub enabled: bool, pub last_run_at: Option>, pub next_run_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSchedule { pub name: String, pub cron: String, #[serde(default)] pub timezone: String, pub instructions: String, #[serde(default)] pub thread_id: Option, #[serde(default = "default_enabled")] pub enabled: bool, } fn default_enabled() -> bool { true } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateSchedule { pub name: Option, pub cron: Option, pub timezone: Option, pub instructions: Option, pub enabled: Option, pub thread_id: Option, } pub fn router() -> Router { Router::new() .route( "/api/bots/{bot_id}/schedules", get(list_http).post(create_http), ) .route( "/api/schedules/{id}", axum::routing::patch(update_http).delete(delete_http), ) .route("/api/schedules/{id}/run", post(run_now_http)) } async fn scoped_actor(state: &AppState, bot_id: &str) -> Result { let actor = state .bootstrap() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; state .db .get_bot(&actor, bot_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; Ok(actor) } async fn list_http( State(state): State, Path(bot_id): Path, ) -> Result>, StatusCode> { let actor = scoped_actor(&state, &bot_id).await?; let rows = list(&state, &actor, &bot_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(rows.into_iter().map(public_json).collect())) } async fn create_http( State(state): State, Path(bot_id): Path, Json(input): Json, ) -> Result, (StatusCode, Json)> { let actor = scoped_actor(&state, &bot_id) .await .map_err(|status| (status, Json(json!({"message":"bot not found"}))))?; create(&state, &actor, &bot_id, input) .await .map(|row| Json(public_json(row))) .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error})))) } async fn update_http( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, (StatusCode, Json)> { let actor = state.bootstrap().await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"message":"actor"})), ) })?; update(&state, &actor, &id, input) .await .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))? .map(|row| Json(public_json(row))) .ok_or(( StatusCode::NOT_FOUND, Json(json!({"message":"schedule not found"})), )) } async fn delete_http( State(state): State, Path(id): Path, ) -> Result { let actor = state .bootstrap() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let deleted = sqlx::query("DELETE FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3") .bind(&id) .bind(&actor.space_id) .bind(&actor.user_id) .execute(state.pool()) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if deleted.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } Ok(StatusCode::NO_CONTENT) } async fn run_now_http( State(state): State, Path(id): Path, ) -> Result, (StatusCode, Json)> { let actor = state.bootstrap().await.map_err(|_| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"message":"actor"})), ) })?; let row = get_row(&state, &actor, &id) .await .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error}))))? .ok_or(( StatusCode::NOT_FOUND, Json(json!({"message":"schedule not found"})), ))?; enqueue(&state, &row, false) .await .map(|run_id| Json(json!({"ok": true, "runId": run_id}))) .map_err(|error| (StatusCode::BAD_REQUEST, Json(json!({"message": error})))) } pub fn public_json(row: ScheduleRow) -> Value { json!({ "id": row.id, "botId": row.bot_id, "threadId": row.thread_id, "name": row.name, "cron": row.cron, "timezone": row.timezone, "instructions": row.instructions, "enabled": row.enabled, "human": describe_cron(&row.cron), "lastRunAt": row.last_run_at, "nextRunAt": row.next_run_at, "createdAt": row.created_at, "updatedAt": row.updated_at, }) } pub async fn list( state: &AppState, actor: &Actor, bot_id: &str, ) -> Result, String> { sqlx::query_as( "SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled, last_run_at, next_run_at, created_at, updated_at FROM schedules WHERE bot_id=$1 AND space_id=$2 AND user_id=$3 ORDER BY enabled DESC, next_run_at ASC NULLS LAST, updated_at DESC", ) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(state.pool()) .await .map_err(|error| error.to_string()) } pub async fn get_row( state: &AppState, actor: &Actor, id: &str, ) -> Result, String> { sqlx::query_as( "SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled, last_run_at, next_run_at, created_at, updated_at FROM schedules WHERE id=$1 AND space_id=$2 AND user_id=$3", ) .bind(id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string()) } pub async fn create( state: &AppState, actor: &Actor, bot_id: &str, input: CreateSchedule, ) -> Result { validate_thread(state, actor, bot_id, input.thread_id.as_deref()).await?; let name = clean_name(&input.name)?; let instructions = clean_instructions(&input.instructions)?; let cron = validate_cron(&input.cron)?; let timezone = validate_timezone(&input.timezone)?; let next = if input.enabled { Some(next_fire(&cron, &timezone, Utc::now())?) } else { None }; let id = Uuid::new_v4().to_string(); sqlx::query_as( "INSERT INTO schedules (id, space_id, user_id, bot_id, thread_id, name, cron, timezone, instructions, enabled, next_run_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled, last_run_at, next_run_at, created_at, updated_at", ) .bind(&id) .bind(&actor.space_id) .bind(&actor.user_id) .bind(bot_id) .bind(input.thread_id.as_deref()) .bind(name) .bind(&cron) .bind(&timezone) .bind(instructions) .bind(input.enabled) .bind(next) .fetch_one(state.pool()) .await .map_err(|error| error.to_string()) } async fn update( state: &AppState, actor: &Actor, id: &str, input: UpdateSchedule, ) -> Result, String> { let existing = match get_row(state, actor, id).await? { Some(row) => row, None => return Ok(None), }; let name = match input.name { Some(value) => clean_name(&value)?, None => existing.name.clone(), }; let instructions = match input.instructions { Some(value) => clean_instructions(&value)?, None => existing.instructions.clone(), }; let cron = match input.cron { Some(value) => validate_cron(&value)?, None => existing.cron.clone(), }; let timezone = match input.timezone { Some(value) => validate_timezone(&value)?, None => existing.timezone.clone(), }; let enabled = input.enabled.unwrap_or(existing.enabled); let thread_id = input.thread_id.or(existing.thread_id); validate_thread(state, actor, &existing.bot_id, thread_id.as_deref()).await?; let next = if enabled { Some(next_fire(&cron, &timezone, Utc::now())?) } else { None }; sqlx::query_as( "UPDATE schedules SET name=$1, cron=$2, timezone=$3, instructions=$4, enabled=$5, thread_id=$6, next_run_at=$7, updated_at=now() WHERE id=$8 AND space_id=$9 AND user_id=$10 RETURNING id, bot_id, thread_id, name, cron, timezone, instructions, enabled, last_run_at, next_run_at, created_at, updated_at", ) .bind(name) .bind(cron) .bind(timezone) .bind(instructions) .bind(enabled) .bind(thread_id) .bind(next) .bind(id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string()) } pub async fn tick_loop(state: AppState) { loop { tokio::time::sleep(std::time::Duration::from_secs(15)).await; if let Err(error) = fire_due(&state).await { tracing::warn!("schedule tick failed: {error}"); } } } async fn fire_due(state: &AppState) -> Result<(), String> { let due: Vec = sqlx::query_as( "SELECT id, bot_id, thread_id, name, cron, timezone, instructions, enabled, last_run_at, next_run_at, created_at, updated_at FROM schedules WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= now() ORDER BY next_run_at ASC LIMIT 8", ) .fetch_all(state.pool()) .await .map_err(|error| error.to_string())?; for row in due { if let Err(error) = enqueue(state, &row, true).await { tracing::warn!("schedule {} failed to enqueue: {error}", row.id); } } Ok(()) } async fn enqueue(state: &AppState, row: &ScheduleRow, from_tick: bool) -> Result { let mut tx = state.pool().begin().await.map_err(|e| e.to_string())?; let locked: Option = if from_tick { sqlx::query_as("SELECT id,bot_id,thread_id,name,cron,timezone,instructions,enabled,last_run_at,next_run_at,created_at,updated_at FROM schedules WHERE id=$1 AND enabled AND next_run_at<=now() FOR UPDATE SKIP LOCKED") .bind(&row.id).fetch_optional(&mut *tx).await.map_err(|e| e.to_string())? } else { Some(row.clone()) }; let Some(row) = locked.as_ref() else { return Ok(String::new()); }; let bot = sqlx::query_as::<_, (String, String, String)>( "SELECT space_id, user_id, id FROM bots WHERE id=$1", ) .bind(&row.bot_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string())? .ok_or_else(|| "bot not found".to_string())?; let thread_id = match row.thread_id.as_deref() { Some(id) => id.to_string(), None => latest_thread(state, &row.bot_id).await?, }; validate_thread( state, &Actor { space_id: bot.0.clone(), user_id: bot.1.clone(), }, &row.bot_id, Some(&thread_id), ) .await?; let prompt = format!("Scheduled task 「{}」:\n{}", row.name, row.instructions); let seq: i32 = sqlx::query_scalar( "UPDATE threads SET next_message_seq=next_message_seq+1, updated_at=now() WHERE id=$1 RETURNING next_message_seq-1", ) .bind(&thread_id) .fetch_one(&mut *tx) .await .map_err(|error| error.to_string())?; let run_id = Uuid::new_v4().to_string(); let message_id = Uuid::new_v4().to_string(); let body = if from_tick { format!("[排程] {}", row.name) } else { format!("[排程試跑] {}", row.name) }; sqlx::query( "INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id) VALUES ($1,$2,$3,'user',$4,$5,$6)", ) .bind(&message_id) .bind(&thread_id) .bind(seq) .bind(&body) .bind(json!([{"kind":"scheduleRun","scheduleId":row.id,"name":row.name,"human":describe_cron(&row.cron)}])) .bind(&run_id) .execute(&mut *tx) .await .map_err(|error| error.to_string())?; sqlx::query( "INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint) VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)", ) .bind(&run_id) .bind(&bot.0) .bind(&row.bot_id) .bind(&thread_id) .bind(&bot.1) .bind(&prompt) .bind(json!({"messageSeq": seq, "scheduleId": row.id})) .execute(&mut *tx) .await .map_err(|error| error.to_string())?; if from_tick { let next = next_after_due( &row.cron, &row.timezone, row.next_run_at.unwrap_or_else(Utc::now), Utc::now(), )?; sqlx::query( "UPDATE schedules SET last_run_at=now(),next_run_at=$2,updated_at=now() WHERE id=$1", ) .bind(&row.id) .bind(next) .execute(&mut *tx) .await .map_err(|e| e.to_string())?; } tx.commit().await.map_err(|error| error.to_string())?; Ok(run_id) } async fn latest_thread(state: &AppState, bot_id: &str) -> Result { sqlx::query_scalar( "SELECT id FROM threads WHERE bot_id=$1 AND status='active' AND room_id IS NULL ORDER BY updated_at DESC LIMIT 1", ) .bind(bot_id) .fetch_optional(state.pool()) .await .map_err(|error| error.to_string())? .ok_or_else(|| "bot has no conversation to run this schedule in".into()) } fn clean_name(name: &str) -> Result { let value = name.trim(); if value.is_empty() || value.chars().count() > 80 { return Err("name must be 1–80 characters".into()); } Ok(value.to_string()) } fn clean_instructions(text: &str) -> Result { let value = text.trim(); if value.is_empty() || value.chars().count() > 8_000 { return Err("instructions must be 1–8000 characters".into()); } Ok(value.to_string()) } pub fn validate_cron(expr: &str) -> Result { let trimmed = expr.trim(); if interval_seconds(trimmed)?.is_none() { parse_schedule(trimmed)?; } Ok(trimmed.to_string()) } fn validate_timezone(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() { return Ok("Asia/Taipei".into()); } Tz::from_str(trimmed).map_err(|_| "invalid IANA timezone".to_string())?; Ok(trimmed.to_string()) } fn interval_seconds(expr: &str) -> Result, String> { let Some(raw) = expr.strip_prefix("@every ") else { return Ok(None); }; if !raw.is_ascii() || raw.len() < 2 { return Err("invalid fixed interval".into()); } let (number, unit) = raw.split_at(raw.len() - 1); let n: i64 = number.parse().map_err(|_| "invalid interval")?; if !(1..=365).contains(&n) { return Err("interval must be 1–365".into()); } let scale = match unit { "m" => 60, "h" => 3600, "d" => 86400, _ => return Err("interval unit must be m, h, or d".into()), }; Ok(Some(n * scale)) } fn parse_schedule(expr: &str) -> Result { let fields: Vec<&str> = expr.split_whitespace().collect(); if fields.len() != 5 { return Err("cron must have exactly 5 fields".into()); } if fields[2] != "*" && fields[4] != "*" { return Err("use either day-of-month or day-of-week, not both".into()); } let weekday = standard_weekdays(fields[4])?; let six = format!( "0 {} {} {} {} {}", fields[0], fields[1], fields[2], fields[3], weekday ); Schedule::from_str(&six).map_err(|error| format!("invalid cron: {error}")) } // UI/API use Unix weekdays (0/7=Sun, 1=Mon); cron crate uses 1=Sun. fn standard_weekdays(field: &str) -> Result { if field == "*" { return Ok("*".into()); } fn value(raw: &str) -> Result { match raw.to_ascii_uppercase().as_str() { "SUN" => Ok(0), "MON" => Ok(1), "TUE" => Ok(2), "WED" => Ok(3), "THU" => Ok(4), "FRI" => Ok(5), "SAT" => Ok(6), _ => raw .parse::() .ok() .filter(|n| *n <= 7) .ok_or_else(|| "invalid weekday".into()), } } let mut days = std::collections::BTreeSet::new(); for part in field.split(',') { let (base, step) = if let Some((base, n)) = part.split_once('/') { ( base, n.parse::() .ok() .filter(|n| (1..=7).contains(n)) .ok_or("invalid weekday step")?, ) } else { (part, 1) }; let (start, end) = if base == "*" { (0, 6) } else if let Some((a, b)) = base.split_once('-') { (value(a)?, value(b)?) } else { let n = value(base)?; (n, n) }; if start > end { return Err("weekday range must be ascending".into()); } for n in (start..=end).step_by(step as usize) { days.insert(n % 7 + 1); } } Ok(days .into_iter() .map(|n| n.to_string()) .collect::>() .join(",")) } pub fn next_fire(expr: &str, timezone: &str, from: DateTime) -> Result, String> { if let Some(seconds) = interval_seconds(expr)? { return Ok(from + chrono::TimeDelta::seconds(seconds)); } let schedule = parse_schedule(expr)?; let tz: Tz = Tz::from_str(timezone).map_err(|_| "invalid IANA timezone")?; let local = from.with_timezone(&tz); schedule .after(&local) .next() .map(|when| when.with_timezone(&Utc)) .ok_or_else(|| "cron has no future run".into()) } pub fn describe_cron(expr: &str) -> String { if let Ok(Some(seconds)) = interval_seconds(expr) { return format!("每隔 {} 分鐘(固定間隔)", seconds / 60); } let parts: Vec<&str> = expr.trim().split_whitespace().collect(); if parts.len() != 5 { return expr.to_string(); } let (min, hour, dom, month, dow) = (parts[0], parts[1], parts[2], parts[3], parts[4]); if expr.trim() == "* * * * *" { return "每分鐘".into(); } if let Some(rest) = min.strip_prefix("*/") { if hour == "*" && dom == "*" && month == "*" && dow == "*" { return if rest .parse::() .ok() .is_some_and(|n| n > 0 && 60 % n == 0) { format!("每 {rest} 分鐘") } else { format!("日曆排程:{expr}") }; } } if min == "0" && hour == "*" && dom == "*" && month == "*" && dow == "*" { return "每小時".into(); } if min == "0" { if let Some(rest) = hour.strip_prefix("*/") { if dom == "*" && month == "*" && dow == "*" { return if rest .parse::() .ok() .is_some_and(|n| n > 0 && 24 % n == 0) { format!("每 {rest} 小時") } else { format!("日曆排程:{expr}") }; } } } if min.parse::().is_ok() && hour.parse::().is_ok() && month == "*" { let at = format!("{hour:0>2}:{min:0>2}"); if dom == "*" && dow == "*" { return format!("每天 {at}"); } if dom == "*" && dow == "1-5" { return format!("工作日 {at}"); } if dom == "*" && dow == "1" { return format!("每週一 {at}"); } if dom == "1" && dow == "*" { return format!("每月 1 日 {at}"); } } expr.to_string() } #[cfg(test)] mod tests { use super::{describe_cron, next_fire, validate_cron}; use chrono::TimeZone; #[test] fn weekday_nine_is_valid() { assert_eq!(validate_cron("0 9 * * 1-5").unwrap(), "0 9 * * 1-5"); assert!(validate_cron("not cron").is_err()); } #[test] fn describes_common_patterns() { assert_eq!(describe_cron("0 9 * * *"), "每天 09:00"); assert_eq!(describe_cron("0 9 * * 1-5"), "工作日 09:00"); assert_eq!(describe_cron("*/15 * * * *"), "每 15 分鐘"); } #[test] fn next_fire_is_in_the_future() { let from = chrono::Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap(); let next = next_fire("0 9 * * *", "Asia/Taipei", from).unwrap(); assert!(next > from); } } async fn validate_thread( state: &AppState, actor: &Actor, bot: &str, thread: Option<&str>, ) -> Result<(), String> { let owns: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3)", ) .bind(bot) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_one(state.pool()) .await .map_err(|e| e.to_string())?; if !owns { return Err("bot not found".into()); } if let Some(id) = thread { let valid: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1 AND bot_id=$2 AND status='active' AND room_id IS NULL)") .bind(id).bind(bot).fetch_one(state.pool()).await.map_err(|e| e.to_string())?; if !valid { return Err("schedule conversation must belong to this bot and be active".into()); } } Ok(()) } fn next_after_due( expr: &str, timezone: &str, due: DateTime, now: DateTime, ) -> Result, String> { if let Some(seconds) = interval_seconds(expr)? { let elapsed = (now - due).num_seconds().max(0); return Ok(due + chrono::TimeDelta::seconds((elapsed / seconds + 1) * seconds)); } next_fire(expr, timezone, now) } #[cfg(test)] mod regression_tests { use super::*; use chrono::{Datelike, TimeZone}; #[test] fn intervals_cross_month_and_dst_without_reset() { let from = Utc.with_ymd_and_hms(2026, 1, 31, 9, 0, 0).unwrap(); assert_eq!( (next_fire("@every 3d", "America/New_York", from).unwrap() - from).num_hours(), 72 ); let dst = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap(); assert_eq!( (next_fire("@every 2d", "America/New_York", dst).unwrap() - dst).num_hours(), 48 ); } #[test] fn weekday_means_monday_through_friday() { let friday = Utc.with_ymd_and_hms(2026, 9, 4, 2, 0, 0).unwrap(); let next = next_fire("0 9 * * 1-5", "Asia/Taipei", friday).unwrap(); assert_eq!(next.weekday(), chrono::Weekday::Mon); } #[test] fn invalid_formats_and_timezones_fail_closed() { for expr in [ "0 0 9 * * 1", "99 25 * * *", "@every 0d", "@every 366h", "@every 5z", ] { assert!(validate_cron(expr).is_err(), "{expr}"); } assert!(validate_timezone("Asia/Typo").is_err()); } #[test] fn missed_intervals_preserve_phase() { let due = Utc.with_ymd_and_hms(2026, 9, 5, 0, 0, 0).unwrap(); assert_eq!( next_after_due( "@every 3m", "UTC", due, due + chrono::TimeDelta::seconds(400) ) .unwrap(), due + chrono::TimeDelta::seconds(540) ); } }