diff --git a/Cargo.lock b/Cargo.lock index 9e32e48..ef90320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -912,6 +912,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "chrono", "jmap-client", "redis", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 67a834b..b54bfa1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,10 @@ edition = "2021" [dependencies] async-trait = "0.1" axum = "0.8" +# Already in Cargo.lock as a jmap-client transitive dep; used only for the +# fixed-offset render of notification timestamps. No IANA DB: chrono-tz is not +# available offline, so only no-DST zones are supported (see config.rs). +chrono = "0.4" # Read-only HTTP JMAP only; keep WebSocket/EventSource out of the dependency graph. jmap-client = { version = "=0.4.2", default-features = false, features = ["async", "rustls"] } redis = { version = "0.27", default-features = false, features = ["aio", "tokio-comp", "streams", "script", "tokio-rustls-comp"] } diff --git a/src/config.rs b/src/config.rs index e83dc3e..16ae40f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,83 @@ pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> { Ok(key) } +/// Default display zone for notification timestamps. +pub fn default_timezone() -> String { + "Asia/Shanghai".into() +} + +/// The only zones this build can render. Each is a fixed-offset zone with no +/// daylight-saving transitions, so a stored offset is correct year round. +/// +/// Only zones with a permanent offset are listed. The IANA database is not +/// available in this build (no `chrono-tz`), so a DST zone would silently +/// report the wrong wall clock time for part of the year. Adding one here is +/// therefore a silent-correctness bug, not a feature. +pub(crate) const SUPPORTED_TIMEZONES: &[(&str, &str, &str)] = &[ + ("Etc/UTC", "UTC", "+00:00"), + ("Africa/Cairo", "埃及开罗", "+02:00"), + ("Europe/Istanbul", "土耳其伊斯坦布尔", "+03:00"), + ("Africa/Nairobi", "肯尼亚内罗毕", "+03:00"), + ("Asia/Dubai", "阿联酋迪拜", "+04:00"), + ("Asia/Karachi", "巴基斯坦卡拉奇", "+05:00"), + ("Asia/Kolkata", "印度加尔各答", "+05:30"), + ("Asia/Bangkok", "泰国曼谷", "+07:00"), + ("Asia/Ho_Chi_Minh", "越南河内", "+07:00"), + ("Asia/Shanghai", "中国上海", "+08:00"), + ("Asia/Hong_Kong", "中国香港", "+08:00"), + ("Asia/Taipei", "中国台北", "+08:00"), + ("Asia/Singapore", "新加坡", "+08:00"), + ("Asia/Manila", "菲律宾马尼拉", "+08:00"), + ("Asia/Tokyo", "日本东京", "+09:00"), + ("Asia/Seoul", "韩国首尔", "+09:00"), +]; + +/// Resolve one supported zone to its fixed offset, in seconds east of UTC. +/// Returns `None` when the name is not in the table. +pub(crate) fn resolve_timezone(iana: &str) -> Option { + SUPPORTED_TIMEZONES + .iter() + .find(|(name, _, _)| *name == iana) + .map(|(_, _, offset)| { + parse_offset_seconds(offset) + .expect("static offsets in SUPPORTED_TIMEZONES parse as ±HH:MM") + }) +} + +/// Parse a `±HH:MM` offset into seconds east of UTC. `None` for a malformed +/// string; the table entries are all well formed, so a failure here is a bug. +pub(crate) fn parse_offset_seconds(offset: &str) -> Option { + let sign = if offset.starts_with('-') { + -1_i32 + } else if offset.starts_with('+') { + 1_i32 + } else { + return None; + }; + let rest = &offset[1..]; + let (hours, minutes) = rest.split_once(':')?; + let hours: i32 = hours.parse().ok()?; + let minutes: i32 = minutes.parse().ok()?; + if hours > 23 || minutes > 59 { + return None; + } + Some(sign * (hours * 3600 + minutes * 60)) +} + +/// Default notification display zone, as a fixed offset in seconds east of UTC. +pub(crate) fn default_timezone_offset() -> i32 { + resolve_timezone(&default_timezone()).expect("the default zone is in the table") +} + +/// Fixed offset for a zone already accepted by `TryFrom`. +/// +/// Configs only reach a worker after the zone name was validated against the table, +/// so the fallback is unreachable; it keeps the helper infallible rather than panicking +/// in a worker construction path. +pub(crate) fn timezone_offset_of(zone: &str) -> i32 { + resolve_timezone(zone).unwrap_or_else(default_timezone_offset) +} + /// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials. /// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly. pub struct Config { @@ -39,6 +116,8 @@ pub struct Config { pub llm: LlmConfig, pub auth: AuthSecrets, pub worker_token: SecretString, + /// Display zone mirrored from `BusinessConfig`; the worker renders timestamps in it. + pub timezone: String, } /// Runtime-only endpoint credentials; never format or log these values. @@ -79,6 +158,8 @@ pub struct BusinessConfig { pub telegram_chat_id: i64, pub chat_allowlist: Vec, pub telegram_webhook_secret: SecretString, + /// Display zone for notification timestamps; always one of SUPPORTED_TIMEZONES. + pub timezone: String, pub jmap_session_url: String, pub jmap_username: String, pub jmap_password: SecretString, @@ -100,6 +181,8 @@ pub(crate) struct BusinessConfigWire { pub telegram_chat_id: i64, pub chat_allowlist: Vec, pub telegram_webhook_secret: String, + #[serde(default = "default_timezone")] + pub timezone: String, pub jmap_session_url: String, pub jmap_username: String, pub jmap_password: String, @@ -121,6 +204,7 @@ impl From for BusinessConfigWire { telegram_chat_id: value.telegram_chat_id, chat_allowlist: value.chat_allowlist, telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(), + timezone: value.timezone, jmap_session_url: value.jmap_session_url, jmap_username: value.jmap_username, jmap_password: value.jmap_password.expose_secret().to_owned(), @@ -147,6 +231,11 @@ impl TryFrom for BusinessConfig { validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?; validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?; validate_nonblank("WORKER_TOKEN", &value.worker_token)?; + if resolve_timezone(&value.timezone).is_none() { + return Err(BotError::Config( + "TIMEZONE must be one of the supported time zones".into(), + )); + } if let Some(key) = value.llm_api_key.as_deref() { validate_nonblank("LLM_API_KEY", key)?; } @@ -166,6 +255,7 @@ impl TryFrom for BusinessConfig { llm_model: value.llm_model, reconcile_token: SecretString::new(value.reconcile_token), worker_token: SecretString::new(value.worker_token), + timezone: value.timezone, }) } } @@ -240,6 +330,7 @@ impl Config { telegram_webhook_secret: empty(), }, worker_token: empty(), + timezone: default_timezone(), } } @@ -271,6 +362,7 @@ impl Config { telegram_webhook_secret: value.telegram_webhook_secret, }, worker_token: value.worker_token, + timezone: value.timezone, } } @@ -307,6 +399,7 @@ mod tests { telegram_chat_id: 1, chat_allowlist: vec![1], telegram_webhook_secret: "hook".into(), + timezone: "Asia/Shanghai".into(), jmap_session_url: "https://example.invalid".into(), jmap_username: "u".into(), jmap_password: AUTH_SECRET_VALUE.into(), @@ -338,6 +431,7 @@ mod tests { telegram_chat_id: 1, chat_allowlist: Vec::new(), telegram_webhook_secret: "hook".into(), + timezone: "Asia/Shanghai".into(), jmap_session_url: "https://example.invalid".into(), jmap_username: "u".into(), jmap_password: "p".into(), @@ -361,6 +455,7 @@ mod tests { telegram_chat_id: 1, chat_allowlist: vec![1, 2], telegram_webhook_secret: "hook".into(), + timezone: "Asia/Shanghai".into(), jmap_session_url: "https://example.invalid".into(), jmap_username: "u".into(), jmap_password: "p".into(), @@ -392,4 +487,125 @@ mod tests { ); } } + + /// A zone outside the table fails validation with the exact message the SPA + /// will surface. + #[test] + fn unsupported_timezone_is_rejected() { + let wire = BusinessConfigWire { + bot_token: "bot".into(), + telegram_chat_id: 1, + chat_allowlist: vec![1], + telegram_webhook_secret: "hook".into(), + timezone: "Europe/London".into(), + jmap_session_url: "https://example.invalid".into(), + jmap_username: "u".into(), + jmap_password: "p".into(), + account_id: None, + llm_enabled: false, + llm_allow_net: false, + llm_api_key: None, + llm_base_url: None, + llm_model: None, + reconcile_token: "r".into(), + worker_token: "w".into(), + }; + let error = validate_business_wire(wire).expect_err("unsupported zone must fail"); + assert_eq!( + error.to_string(), + "configuration error: TIMEZONE must be one of the supported time zones" + ); + } + + /// Every listed zone must validate. + #[test] + fn supported_timezones_are_accepted() { + for (iana, _, offset) in SUPPORTED_TIMEZONES { + let wire = BusinessConfigWire { + bot_token: "bot".into(), + telegram_chat_id: 1, + chat_allowlist: vec![1], + telegram_webhook_secret: "hook".into(), + timezone: (*iana).to_owned(), + jmap_session_url: "https://example.invalid".into(), + jmap_username: "u".into(), + jmap_password: "p".into(), + account_id: None, + llm_enabled: false, + llm_allow_net: false, + llm_api_key: None, + llm_base_url: None, + llm_model: None, + reconcile_token: "r".into(), + worker_token: "w".into(), + }; + validate_business_wire(wire) + .unwrap_or_else(|e| panic!("{iana} ({offset}) must validate: {e:?}")); + } + + assert_eq!(default_timezone(), "Asia/Shanghai"); + assert_eq!(parse_offset_seconds("+08:00"), Some(8 * 3600)); + assert_eq!(parse_offset_seconds("+05:30"), Some(5 * 3600 + 30 * 60)); + assert_eq!(parse_offset_seconds("+00:00"), Some(0)); + assert_eq!(parse_offset_seconds("-04:00"), Some(-4 * 3600)); + assert!(parse_offset_seconds("not-an-offset").is_none()); + } + + /// Persisted configs written before this field existed must still load, with + /// the default zone applied rather than a deserialization error. + #[test] + fn missing_timezone_defaults_to_shanghai() { + let value: BusinessConfigWire = serde_json::from_value(serde_json::json!({ + "bot_token": "bot", + "telegram_chat_id": 1, + "chat_allowlist": [1], + "telegram_webhook_secret": "hook", + "jmap_session_url": "https://example.invalid", + "jmap_username": "u", + "jmap_password": "p", + "llm_enabled": false, + "llm_allow_net": false, + "reconcile_token": "r", + "worker_token": "w", + })) + .expect("a config without timezone must deserialize"); + + assert_eq!(value.timezone, "Asia/Shanghai"); + validate_business_wire(value.clone()).expect("a defaulted zone must validate"); + let config: BusinessConfig = value.try_into().expect("a valid wire must convert"); + assert_eq!(config.timezone, "Asia/Shanghai"); + assert_eq!( + resolve_timezone(&config.timezone), + Some(default_timezone_offset()) + ); + } + + #[test] + fn timezone_survives_the_wire_roundtrip() { + let config = BusinessConfig { + bot_token: secrecy::SecretString::new("bot".into()), + telegram_chat_id: 1, + chat_allowlist: vec![1], + telegram_webhook_secret: secrecy::SecretString::new("hook".into()), + timezone: "Asia/Kolkata".into(), + jmap_session_url: "https://example.invalid".into(), + jmap_username: "u".into(), + jmap_password: secrecy::SecretString::new("p".into()), + account_id: None, + llm_enabled: false, + llm_allow_net: false, + llm_api_key: None, + llm_base_url: None, + llm_model: None, + reconcile_token: secrecy::SecretString::new("r".into()), + worker_token: secrecy::SecretString::new("w".into()), + }; + + let wire = BusinessConfigWire::from(config); + assert_eq!(wire.timezone, "Asia/Kolkata"); + assert_eq!( + serde_json::to_value(&wire).unwrap()["timezone"], + "Asia/Kolkata" + ); + } } diff --git a/src/main.rs b/src/main.rs index fae2ba8..72146dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,6 +176,7 @@ async fn main() -> Result<(), error::BotError> { chat_id, worker_state, llm, + crate::config::timezone_offset_of(&config.timezone), )) as Arc, Err(_) => { tracing::warn!( diff --git a/src/notify.rs b/src/notify.rs index 2d70125..b229534 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -950,6 +950,7 @@ async fn build_worker_report( config.telegram_chat_id, app.state, llm, + crate::config::timezone_offset_of(&config.timezone), ))) } diff --git a/src/worker.rs b/src/worker.rs index d7ad094..6025a04 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -155,6 +155,8 @@ pub struct MetadataWorker { chat_id: i64, state: Arc, llm: Option>, + /// Fixed offset, seconds east of UTC, for the configured display zone. + timezone_offset: i32, } impl MetadataWorker { @@ -164,6 +166,7 @@ impl MetadataWorker { chat_id: i64, state: Arc, llm: Option>, + timezone_offset: i32, ) -> Self { Self { jmap, @@ -171,10 +174,27 @@ impl MetadataWorker { chat_id, state, llm, + timezone_offset, } } } +/// Render a unix-seconds timestamp as wall clock time in a fixed-offset zone. +/// +/// `offset_seconds` must be within +/-24h, which holds for every entry of +/// `SUPPORTED_TIMEZONES`; out-of-range values return `None` instead of panicking. +fn format_received_at(unix_seconds: i64, offset_seconds: i32) -> Option { + use chrono::{DateTime, FixedOffset}; + + let offset = FixedOffset::east_opt(offset_seconds)?; + let utc = DateTime::from_timestamp(unix_seconds, 0)?; + Some( + utc.with_timezone(&offset) + .format("%Y-%m-%d %H:%M") + .to_string(), + ) +} + #[async_trait] impl WorkerHandler for MetadataWorker { async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), ()> { @@ -236,7 +256,7 @@ impl WorkerHandler for MetadataWorker { subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()), received_at: metadata .received_at - .map(|v| v.to_string()) + .and_then(|unix| format_received_at(unix, self.timezone_offset)) .unwrap_or_else(|| "unknown".into()), }, ) @@ -996,6 +1016,7 @@ mod reload_tests { llm_model: None, reconcile_token: SecretString::new("reconcile".into()), worker_token: SecretString::new("worker".into()), + timezone: "Asia/Shanghai".into(), } } @@ -1042,4 +1063,44 @@ mod reload_tests { Ok(()) } } + + /// `1700000000` is `2023-11-14T22:13:20Z`. These are the literal expected + /// wall clock strings, so an offset regression shows up as a value mismatch + /// rather than a silently shifted time. + #[test] + fn received_at_renders_in_the_configured_zone() { + use crate::config::{default_timezone, resolve_timezone}; + + let unix = 1_700_000_000_i64; + let shanghai = resolve_timezone("Asia/Shanghai").unwrap(); + let tokyo = resolve_timezone("Asia/Tokyo").unwrap(); + let utc = resolve_timezone("Etc/UTC").unwrap(); + let kolkata = resolve_timezone("Asia/Kolkata").unwrap(); + + assert_eq!( + format_received_at(unix, shanghai), + Some("2023-11-15 06:13".into()) + ); + assert_eq!( + format_received_at(unix, tokyo), + Some("2023-11-15 07:13".into()) + ); + assert_eq!( + format_received_at(unix, utc), + Some("2023-11-14 22:13".into()) + ); + // Half-hour zone: the :30 offset must survive the round trip. + assert_eq!( + format_received_at(unix, kolkata), + Some("2023-11-15 03:43".into()) + ); + + // The configured default is the zone the SPA ships with. + assert_eq!(default_timezone(), "Asia/Shanghai"); + assert_eq!(resolve_timezone(&default_timezone()), Some(8 * 3600)); + + // Out-of-range values fall back to "unknown" upstream instead of panicking. + assert_eq!(format_received_at(i64::MIN, shanghai), None); + assert_eq!(format_received_at(unix, 25 * 3600), None); + } }