AnyBot/src/routines.rs

478 lines
20 KiB
Rust
Raw Normal View History

// 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())
}
/// The person's time zone (their setting), for times shown to them by an AnyBot whose own clock
/// may be elsewhere: a brain on a server runs in UTC, and a page 「更新於 今天 01:03」 meant 09:03.
static PERSON_TZ: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// A zone that only says where the computer is, not where the person is (a server's UTC).
pub fn is_server_default_zone(tz: &str) -> bool {
matches!(tz.trim(), "" | "UTC" | "Etc/UTC" | "Etc/Universal" | "Universal" | "Zulu" | "GMT" | "Etc/GMT")
}
pub fn set_person_timezone(tz: &str) {
*PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner()) = tz.to_string();
}
pub fn person_tz() -> Tz {
tz_of(&PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner()))
}
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();
let hour0 = (created + off).div_euclid(3600);
let first = (after + off).div_euclid(3600);
for h in first..first + n * 2 + 2 {
let ts = h * 3600 + minute * 60 - off;
if ts > after && (h - hour0).rem_euclid(n) == 0 {
return Some(ts);
}
}
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 {
#[test]
fn a_servers_utc_is_not_the_persons_zone() {
assert!(super::is_server_default_zone("Etc/UTC") && super::is_server_default_zone("") && super::is_server_default_zone("UTC"));
assert!(!super::is_server_default_zone("Asia/Taipei") && !super::is_server_default_zone("Europe/London"));
}
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());
}
}