// Routines: tasks a bot repeats on a schedule. The schedule has the same shape as the original's // (index.eager-app @1508626, `Prt` / `Irt`): frequency daily|weekly|monthly|yearly, an interval, // weekdays MO..SU (`yA`), a day of the month or "the Nth weekday", a month, a time "HH:MM" and a // time zone. Descriptions use the original's zh-TW wording (ids UdWx3W, orHQyz, W+dYrs, Y8bJts, // kToBIG, kfbKQI, h3xQKP, ni0dzI, 1HiPWu, hlCyZb, kGmiCr, GGSF1N, gjpnJh, jjNxA6). use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Utc, Weekday}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; /// The original's weekday order (`yA=["MO","TU","WE","TH","FR","SA","SU"]`). pub const WEEKDAYS: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; pub const WORKWEEK: [&str; 5] = ["MO", "TU", "WE", "TH", "FR"]; const WEEKDAY_ZH: [&str; 7] = ["週一", "週二", "週三", "週四", "週五", "週六", "週日"]; const WEEKDAY_ZH_LONG: [&str; 7] = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]; /// How far ahead to look for the next run (estimated). const SEARCH_DAYS: i64 = 366 * 5; #[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(rename_all = "lowercase")] pub enum Frequency { /// 每 N 分鐘 (counted from when it was made). Minutely, /// 每小時 / 每 N 小時, at the minute of `time`. Hourly, Daily, Weekly, Monthly, Yearly, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct WeekdayOfMonth { /// 1..=4, or -1 for the last one. pub week: i32, pub day: String, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct Schedule { pub frequency: Frequency, #[serde(default = "one")] pub interval: u32, #[serde(default, rename = "daysOfWeek", skip_serializing_if = "Vec::is_empty")] pub days_of_week: Vec, #[serde(default, rename = "monthDay", skip_serializing_if = "Option::is_none")] pub month_day: Option, #[serde(default, rename = "weekdayOfMonth", skip_serializing_if = "Option::is_none")] pub weekday_of_month: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub month: Option, /// "HH:MM", 24-hour. pub time: String, pub timezone: String, /// 僅一次: runs once, then stops. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub once: bool, /// 「左右」: a flexible time, anywhere in ±FLEX_MINUTES (the original's flexible mode). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub flexible: bool, } /// How far a flexible ("左右") time may move either way (estimated; the original has a window). pub const FLEX_MINUTES: i64 = 15; /// Shortest repeat for 每 N 分鐘 (estimated). pub const MIN_MINUTES: u32 = 5; fn one() -> u32 { 1 } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Routine { pub id: u64, pub title: String, /// What the bot is asked to do each time. pub prompt: String, pub schedule: Schedule, #[serde(default)] pub paused: bool, /// Seconds since 1970 (UTC). #[serde(default)] pub created_at: i64, #[serde(default)] pub last_run: Option, #[serde(default)] pub next_run: Option, /// Which computer to work on ("" = the bot's default). #[serde(default)] pub computer: String, /// Run when something happens instead of on the schedule. #[serde(default, skip_serializing_if = "Option::is_none")] pub trigger: Option, } pub fn local_timezone() -> String { iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into()) } fn tz_of(name: &str) -> Tz { name.parse::().or_else(|_| local_timezone().parse::()).unwrap_or(chrono_tz::Asia::Taipei) } fn weekday_code(w: Weekday) -> &'static str { WEEKDAYS[w.num_days_from_monday() as usize] } fn weekday_index(code: &str) -> Option { WEEKDAYS.iter().position(|w| w.eq_ignore_ascii_case(code)) } pub fn parse_time(s: &str) -> Option { NaiveTime::parse_from_str(s.trim(), "%H:%M").ok() } /// Check a schedule a model wrote, and tidy it. pub fn validate(mut s: Schedule) -> Result { if s.interval == 0 { s.interval = 1; } if parse_time(&s.time).is_none() { return Err(format!("時間「{}」看不懂,請用 24 小時制的 HH:MM,例如 09:00", s.time)); } if s.timezone.trim().is_empty() || s.timezone.parse::().is_err() { s.timezone = local_timezone(); } s.days_of_week = s.days_of_week.iter().filter_map(|d| weekday_index(d).map(|i| WEEKDAYS[i].to_string())).collect(); s.days_of_week.sort_by_key(|d| weekday_index(d)); s.days_of_week.dedup(); if s.frequency == Frequency::Minutely && s.interval < MIN_MINUTES { return Err(format!("每 N 分鐘最少 {MIN_MINUTES} 分鐘")); } match s.frequency { Frequency::Weekly if s.days_of_week.is_empty() => return Err("每週的例行任務要指定星期幾(daysOfWeek,例如 [\"MO\",\"WE\"])".into()), Frequency::Monthly if s.month_day.is_none() && s.weekday_of_month.is_none() => s.month_day = Some(1), Frequency::Yearly if s.month.is_none() => s.month = Some(1), _ => {} } if let Some(d) = s.month_day { if !(1..=31).contains(&d) { return Err("每月的日期要在 1 到 31 之間".into()); } } Ok(s) } /// "每週的週一、週三 09:00" in the original's wording. pub fn describe(s: &Schedule) -> String { let n = s.interval.max(1); if s.frequency == Frequency::Minutely { return format!("每 {n} 分鐘{}", if s.once { "(僅一次)" } else { "" }); } if s.frequency == Frequency::Hourly { let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t)).unwrap_or(0); let base = if n > 1 { format!("每 {n} 小時") } else { "每小時".into() }; return format!("{base}的 {minute:02} 分{}{}", if s.flexible { "左右" } else { "" }, if s.once { "(僅一次)" } else { "" }); } let unit = match s.frequency { Frequency::Minutely | Frequency::Hourly => unreachable!(), Frequency::Daily => "天", Frequency::Weekly => "週", Frequency::Monthly => "個月", Frequency::Yearly => "年", }; let duration = format!("{n} {unit}"); let repeats = match s.frequency { Frequency::Minutely | Frequency::Hourly => unreachable!(), Frequency::Daily => if n > 1 { format!("每{duration}") } else { "每天".into() }, Frequency::Weekly => { let all_work = s.days_of_week.len() == WORKWEEK.len() && WORKWEEK.iter().all(|w| s.days_of_week.iter().any(|d| d == w)); if n == 1 && all_work { "每個工作日".into() } else { let names: Vec<&str> = s.days_of_week.iter().filter_map(|d| weekday_index(d)).map(|i| WEEKDAY_ZH[i]).collect(); let names = names.join("、"); if n > 1 { format!("每{duration}的{names}") } else { format!("每週的{names}") } } } Frequency::Monthly => match &s.weekday_of_month { None => { let d = s.month_day.unwrap_or(1); if n > 1 { format!("每{duration}的{d}日") } else { format!("每月{d}日") } } Some(w) => { let week = if w.week < 0 { "最後一個".to_string() } else { format!("第{}個", w.week) }; let day = weekday_index(&w.day).map(|i| WEEKDAY_ZH_LONG[i]).unwrap_or(""); if n > 1 { format!("每{duration}的{week}{day}") } else { format!("每月的{week}{day}") } } }, Frequency::Yearly => { let date = format!("{}月{}日", s.month.unwrap_or(1), s.month_day.unwrap_or(1)); if n > 1 { format!("每{duration}的{date}") } else { format!("每年{date}") } } }; let sentence = format!("{}{repeats} {}{}", if s.once { "僅一次:" } else { "" }, s.time, if s.flexible { "左右" } else { "" }); if s.timezone == local_timezone() { sentence } else { format!("{sentence}({})", s.timezone) } } fn months_between(a: NaiveDate, b: NaiveDate) -> i64 { (b.year() as i64 - a.year() as i64) * 12 + b.month() as i64 - a.month() as i64 } fn nth_weekday_matches(d: NaiveDate, w: &WeekdayOfMonth) -> bool { let Some(i) = weekday_index(&w.day) else { return false }; if d.weekday().num_days_from_monday() as usize != i { return false; } if w.week < 0 { (d + Duration::days(7)).month() != d.month() } else { ((d.day() - 1) / 7 + 1) as i32 == w.week } } fn day_matches(s: &Schedule, anchor: NaiveDate, d: NaiveDate) -> bool { let n = s.interval.max(1) as i64; match s.frequency { Frequency::Minutely | Frequency::Hourly => true, Frequency::Daily => (d - anchor).num_days().rem_euclid(n) == 0, Frequency::Weekly => { let week0 = anchor - Duration::days(anchor.weekday().num_days_from_monday() as i64); let weeks = (d - week0).num_days().div_euclid(7); weeks.rem_euclid(n) == 0 && s.days_of_week.iter().any(|w| w == weekday_code(d.weekday())) } Frequency::Monthly => { months_between(anchor, d).rem_euclid(n) == 0 && match &s.weekday_of_month { Some(w) => nth_weekday_matches(d, w), // A day past the month's end runs on its last day. None => { let want = s.month_day.unwrap_or(1); let last = (d + Duration::days(1)).month() != d.month(); d.day() == want || (last && d.day() < want) } } } Frequency::Yearly => { (d.year() - anchor.year()) as i64 % n == 0 && d.month() == s.month.unwrap_or(1) && d.day() == s.month_day.unwrap_or(1) } } } /// The first run strictly after `after`, counting intervals from `created`. A flexible time moves /// by a steady amount within ±FLEX_MINUTES; a one-time schedule has no run after its first. pub fn next_run(s: &Schedule, created: i64, after: i64) -> Option { if s.once { let first = exact_next(s, created, created - 1)?; let first = flex(s, created, first); return (first > after).then_some(first); } // Look a little earlier so a time pulled forward by "左右" isn't skipped. let pad = if s.flexible { FLEX_MINUTES * 60 } else { 0 }; let mut from = after - pad; for _ in 0..4 { let t = exact_next(s, created, from)?; let f = flex(s, created, t); if f > after { return Some(f); } from = t; } None } fn flex(s: &Schedule, created: i64, t: i64) -> i64 { if !s.flexible { return t; } // A steady shift per run (same routine + same slot → same moment). let h = (created as u64 ^ (t as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)).wrapping_mul(0xBF58_476D_1CE4_E5B9) >> 33; t + (h % (2 * FLEX_MINUTES as u64 * 60 + 1)) as i64 - FLEX_MINUTES * 60 } fn exact_next(s: &Schedule, created: i64, after: i64) -> Option { let n = s.interval.max(1) as i64; if s.frequency == Frequency::Minutely { let step = n * 60; let k = ((after - created).div_euclid(step) + 1).max(1); return Some(created + k * step); } if s.frequency == Frequency::Hourly { let minute = parse_time(&s.time).map(|t| chrono::Timelike::minute(&t) as i64).unwrap_or(0); let hour0 = created.div_euclid(3600); let mut h = after.div_euclid(3600); for _ in 0..(n * 2 + 2) { let ts = h * 3600 + minute * 60; if ts > after && (h - hour0).rem_euclid(n) == 0 { return Some(ts); } h += 1; } return None; } let tz = tz_of(&s.timezone); let time = parse_time(&s.time)?; let anchor = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz).date_naive(); let start = Utc.timestamp_opt(after, 0).single()?.with_timezone(&tz).date_naive(); for i in 0..SEARCH_DAYS { let d = start + Duration::days(i); if !day_matches(s, anchor, d) { continue; } // Skipped or doubled clock times (summer time) take the earliest valid moment. let Some(at) = tz.from_local_datetime(&d.and_time(time)).earliest() else { continue }; let ts = at.timestamp(); if ts > after { return Some(ts); } } None } /// "今天 09:00", "明天 09:00" or "11月5日 09:00" in the routine's time zone. pub fn describe_when(ts: i64, timezone: &str) -> String { let tz = tz_of(timezone); let Some(t) = Utc.timestamp_opt(ts, 0).single().map(|t| t.with_timezone(&tz)) else { return String::new() }; let today = Utc::now().with_timezone(&tz).date_naive(); let day = match (t.date_naive() - today).num_days() { 0 => "今天".to_string(), 1 => "明天".to_string(), _ => format!("{}月{}日", t.month(), t.day()), }; format!("{day} {}", t.format("%H:%M")) } pub fn now() -> i64 { Utc::now().timestamp() } pub fn now_in(timezone: &str) -> DateTime { Utc::now().with_timezone(&tz_of(timezone)) } #[cfg(test)] mod tests { use super::*; fn ts(y: i32, m: u32, d: u32, h: u32, mi: u32) -> i64 { chrono_tz::Asia::Taipei.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap().timestamp() } /// Drop the "(Asia/Taipei)" a computer in another timezone adds. fn here(s: String) -> String { s.trim_end_matches("(Asia/Taipei)").to_string() } fn sched(f: Frequency) -> Schedule { Schedule { frequency: f, interval: 1, days_of_week: vec![], month_day: None, weekday_of_month: None, month: None, time: "09:00".into(), timezone: "Asia/Taipei".into(), once: false, flexible: false } } #[test] fn hourly_minutes_once_and_flexible() { let mut h = sched(Frequency::Hourly); h.time = "00:15".into(); assert_eq!(next_run(&h, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 20)), Some(ts(2026, 11, 3, 9, 15))); h.interval = 3; assert_eq!(next_run(&h, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 20)), Some(ts(2026, 11, 3, 11, 15))); assert_eq!(describe(&h), "每 3 小時的 15 分"); let mut m = sched(Frequency::Minutely); m.interval = 30; assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 31)), Some(ts(2026, 11, 3, 9, 0))); m.interval = 2; assert!(validate(m).is_err()); let mut o = sched(Frequency::Daily); o.once = true; assert_eq!(next_run(&o, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 3, 9, 0))); assert_eq!(next_run(&o, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 9, 0)), None); let mut f = sched(Frequency::Daily); f.flexible = true; let t = next_run(&f, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)).unwrap(); assert!((t - ts(2026, 11, 3, 9, 0)).abs() <= FLEX_MINUTES * 60); assert_eq!(next_run(&f, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(t)); assert!(here(describe(&f)).ends_with("09:00左右")); let t2 = next_run(&f, ts(2026, 11, 3, 8, 0), t).unwrap(); assert!(t2 > t && (t2 - ts(2026, 11, 4, 9, 0)).abs() <= FLEX_MINUTES * 60); } #[test] fn daily_and_weekly() { let s = sched(Frequency::Daily); // 2026-11-03 is a Tuesday. assert_eq!(next_run(&s, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 3, 9, 0))); assert_eq!(next_run(&s, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 9, 0)), Some(ts(2026, 11, 4, 9, 0))); let mut w = sched(Frequency::Weekly); w.days_of_week = vec!["MO".into(), "FR".into()]; assert_eq!(next_run(&w, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 6, 9, 0))); w.interval = 2; assert_eq!(next_run(&w, ts(2026, 11, 3, 8, 0), ts(2026, 11, 7, 8, 0)), Some(ts(2026, 11, 16, 9, 0))); assert_eq!(here(describe(&w)), "每2 週的週一、週五 09:00"); w.interval = 1; w.days_of_week = WORKWEEK.iter().map(|s| s.to_string()).collect(); assert_eq!(here(describe(&w)), "每個工作日 09:00"); } #[test] fn monthly_rules() { let mut m = sched(Frequency::Monthly); m.month_day = Some(31); // November has 30 days: runs on the 30th. assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 30, 9, 0))); m.month_day = None; m.weekday_of_month = Some(WeekdayOfMonth { week: -1, day: "FR".into() }); assert_eq!(next_run(&m, ts(2026, 11, 3, 8, 0), ts(2026, 11, 3, 8, 0)), Some(ts(2026, 11, 27, 9, 0))); assert_eq!(here(describe(&m)), "每月的最後一個星期五 09:00"); } #[test] fn validation() { let mut w = sched(Frequency::Weekly); assert!(validate(w.clone()).is_err()); w.days_of_week = vec!["fr".into(), "MO".into(), "MO".into()]; w.time = "7:30".into(); let v = validate(w).unwrap(); assert_eq!(v.days_of_week, vec!["MO", "FR"]); let mut bad = sched(Frequency::Daily); bad.time = "noon".into(); assert!(validate(bad).is_err()); } }