AnyBot/src/routines.rs

456 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<String>,
#[serde(default, rename = "monthDay", skip_serializing_if = "Option::is_none")]
pub month_day: Option<u32>,
#[serde(default, rename = "weekdayOfMonth", skip_serializing_if = "Option::is_none")]
pub weekday_of_month: Option<WeekdayOfMonth>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub month: Option<u32>,
/// "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<i64>,
#[serde(default)]
pub next_run: Option<i64>,
/// 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<crate::triggers::Trigger>,
}
pub fn local_timezone() -> String {
iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into())
}
fn tz_of(name: &str) -> Tz {
name.parse::<Tz>().or_else(|_| local_timezone().parse::<Tz>()).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<usize> {
WEEKDAYS.iter().position(|w| w.eq_ignore_ascii_case(code))
}
pub fn parse_time(s: &str) -> Option<NaiveTime> {
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<Schedule, String> {
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::<Tz>().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} 分鐘"));
}
// Keep the "next run" maths (which searches interval-sized steps) bounded, and absurd
// intervals out: a huge hourly interval used to spin the UI thread for minutes on create.
let cap: u32 = match s.frequency {
Frequency::Minutely => 7 * 24 * 60,
Frequency::Hourly => 31 * 24,
Frequency::Daily => 366,
Frequency::Weekly => 53,
Frequency::Monthly => 120,
Frequency::Yearly => 10,
};
if s.interval > cap {
return Err(format!("間隔太大(這種頻率最多每 {cap} 單位一次)"));
}
if s.frequency == Frequency::Yearly {
let (m, d) = (s.month.unwrap_or(1), s.month_day.unwrap_or(1));
// 2 月 31 日永遠不會來:這個排程建立即失效,當初卻毫無警告。
if chrono::NaiveDate::from_ymd_opt(2024, m, d).is_none() {
return Err(format!("每年 {m} 月 {d} 日不存在,無法排入"));
}
}
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<i64> {
if s.once {
let first = exact_next(s, created, created - 1)?;
// A one-time run is never flexed: flexing its only moment into the past made the task
// born already-late and it died silently (next_run = None).
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<i64> {
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);
// Count hours in the schedule's own timezone: its hour line sits at a different
// epoch minute in +05:30/+05:45 — anchoring hours to UTC ran those zones 30/45 late.
let tz = tz_of(&s.timezone);
let at = Utc.timestamp_opt(created, 0).single()?.with_timezone(&tz);
// Offset seconds of that zone at `created` (local wall clock minus UTC).
let off = (at.naive_local() - at.naive_utc()).num_seconds() as i64;
let hour0 = (created + off).div_euclid(3600);
let mut h = (after + off).div_euclid(3600);
for _ in 0..(n * 2 + 2) {
let ts = h * 3600 + minute * 60 - off;
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): a doubled time takes its first
// occurrence; a skipped one (spring-forward) runs just after the gap, not never.
let local = d.and_time(time);
let at = match tz.from_local_datetime(&local) {
chrono::LocalResult::Single(at) => at,
chrono::LocalResult::Ambiguous(at, _) => at,
chrono::LocalResult::None => match tz.from_local_datetime(&(local + Duration::hours(1))).earliest() {
Some(at) => at,
None => 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<Tz> {
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());
}
}