feat(config): make the notification time zone configurable

The Received line was sent as a raw unix timestamp. It is now a business
configuration field (`timezone`, IANA name, default `Asia/Shanghai`), edited
in the admin SPA and applied through the existing 1s hot-reload.

The offline build has no IANA tz database (chrono-tz is unavailable), so the
server keeps a static table of 16 zones that carry no daylight saving
transition and rejects anything else with the existing 422 configuration
error instead of guessing an offset.

`format_received_at` renders to `%Y-%m-%d %H:%M` at notification time; a
timestamp outside the representable range falls back to `unknown`.

Chrono 0.4 was already in Cargo.lock as a jmap-client transitive dependency,
so this only promotes it to a direct dependency.
This commit is contained in:
anonsaber 2026-09-29 21:48:04 +08:00
parent 5e32fe842a
commit d32d645322
6 changed files with 285 additions and 1 deletions

1
Cargo.lock generated
View File

@ -912,6 +912,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono",
"jmap-client", "jmap-client",
"redis", "redis",
"reqwest", "reqwest",

View File

@ -6,6 +6,10 @@ edition = "2021"
[dependencies] [dependencies]
async-trait = "0.1" async-trait = "0.1"
axum = "0.8" 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. # 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"] } 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"] } redis = { version = "0.27", default-features = false, features = ["aio", "tokio-comp", "streams", "script", "tokio-rustls-comp"] }

View File

@ -30,6 +30,83 @@ pub fn encryption_key_from_env() -> Result<[u8; 32], BotError> {
Ok(key) 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<i32> {
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<i32> {
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<BusinessConfigWire>`.
///
/// 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. /// Runtime-only settings. SecretString prevents accidental formatting/logging of credentials.
/// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly. /// C-AUTH-APP-BASIC and REQ-SINGLE-ACCOUNT are intentionally represented explicitly.
pub struct Config { pub struct Config {
@ -39,6 +116,8 @@ pub struct Config {
pub llm: LlmConfig, pub llm: LlmConfig,
pub auth: AuthSecrets, pub auth: AuthSecrets,
pub worker_token: SecretString, 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. /// Runtime-only endpoint credentials; never format or log these values.
@ -79,6 +158,8 @@ pub struct BusinessConfig {
pub telegram_chat_id: i64, pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>, pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: SecretString, 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_session_url: String,
pub jmap_username: String, pub jmap_username: String,
pub jmap_password: SecretString, pub jmap_password: SecretString,
@ -100,6 +181,8 @@ pub(crate) struct BusinessConfigWire {
pub telegram_chat_id: i64, pub telegram_chat_id: i64,
pub chat_allowlist: Vec<i64>, pub chat_allowlist: Vec<i64>,
pub telegram_webhook_secret: String, pub telegram_webhook_secret: String,
#[serde(default = "default_timezone")]
pub timezone: String,
pub jmap_session_url: String, pub jmap_session_url: String,
pub jmap_username: String, pub jmap_username: String,
pub jmap_password: String, pub jmap_password: String,
@ -121,6 +204,7 @@ impl From<BusinessConfig> for BusinessConfigWire {
telegram_chat_id: value.telegram_chat_id, telegram_chat_id: value.telegram_chat_id,
chat_allowlist: value.chat_allowlist, chat_allowlist: value.chat_allowlist,
telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(), telegram_webhook_secret: value.telegram_webhook_secret.expose_secret().to_owned(),
timezone: value.timezone,
jmap_session_url: value.jmap_session_url, jmap_session_url: value.jmap_session_url,
jmap_username: value.jmap_username, jmap_username: value.jmap_username,
jmap_password: value.jmap_password.expose_secret().to_owned(), jmap_password: value.jmap_password.expose_secret().to_owned(),
@ -147,6 +231,11 @@ impl TryFrom<BusinessConfigWire> for BusinessConfig {
validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?; validate_nonblank("TG_WEBHOOK_SECRET", &value.telegram_webhook_secret)?;
validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?; validate_nonblank("RECONCILE_TOKEN", &value.reconcile_token)?;
validate_nonblank("WORKER_TOKEN", &value.worker_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() { if let Some(key) = value.llm_api_key.as_deref() {
validate_nonblank("LLM_API_KEY", key)?; validate_nonblank("LLM_API_KEY", key)?;
} }
@ -166,6 +255,7 @@ impl TryFrom<BusinessConfigWire> for BusinessConfig {
llm_model: value.llm_model, llm_model: value.llm_model,
reconcile_token: SecretString::new(value.reconcile_token), reconcile_token: SecretString::new(value.reconcile_token),
worker_token: SecretString::new(value.worker_token), worker_token: SecretString::new(value.worker_token),
timezone: value.timezone,
}) })
} }
} }
@ -240,6 +330,7 @@ impl Config {
telegram_webhook_secret: empty(), telegram_webhook_secret: empty(),
}, },
worker_token: empty(), worker_token: empty(),
timezone: default_timezone(),
} }
} }
@ -271,6 +362,7 @@ impl Config {
telegram_webhook_secret: value.telegram_webhook_secret, telegram_webhook_secret: value.telegram_webhook_secret,
}, },
worker_token: value.worker_token, worker_token: value.worker_token,
timezone: value.timezone,
} }
} }
@ -307,6 +399,7 @@ mod tests {
telegram_chat_id: 1, telegram_chat_id: 1,
chat_allowlist: vec![1], chat_allowlist: vec![1],
telegram_webhook_secret: "hook".into(), telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(), jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(), jmap_username: "u".into(),
jmap_password: AUTH_SECRET_VALUE.into(), jmap_password: AUTH_SECRET_VALUE.into(),
@ -338,6 +431,7 @@ mod tests {
telegram_chat_id: 1, telegram_chat_id: 1,
chat_allowlist: Vec::new(), chat_allowlist: Vec::new(),
telegram_webhook_secret: "hook".into(), telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(), jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(), jmap_username: "u".into(),
jmap_password: "p".into(), jmap_password: "p".into(),
@ -361,6 +455,7 @@ mod tests {
telegram_chat_id: 1, telegram_chat_id: 1,
chat_allowlist: vec![1, 2], chat_allowlist: vec![1, 2],
telegram_webhook_secret: "hook".into(), telegram_webhook_secret: "hook".into(),
timezone: "Asia/Shanghai".into(),
jmap_session_url: "https://example.invalid".into(), jmap_session_url: "https://example.invalid".into(),
jmap_username: "u".into(), jmap_username: "u".into(),
jmap_password: "p".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"
);
}
} }

View File

@ -176,6 +176,7 @@ async fn main() -> Result<(), error::BotError> {
chat_id, chat_id,
worker_state, worker_state,
llm, llm,
crate::config::timezone_offset_of(&config.timezone),
)) as Arc<dyn WorkerHandler>, )) as Arc<dyn WorkerHandler>,
Err(_) => { Err(_) => {
tracing::warn!( tracing::warn!(

View File

@ -950,6 +950,7 @@ async fn build_worker_report(
config.telegram_chat_id, config.telegram_chat_id,
app.state, app.state,
llm, llm,
crate::config::timezone_offset_of(&config.timezone),
))) )))
} }

View File

@ -155,6 +155,8 @@ pub struct MetadataWorker<B> {
chat_id: i64, chat_id: i64,
state: Arc<dyn ReliableState>, state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>, llm: Option<Arc<LlmClient>>,
/// Fixed offset, seconds east of UTC, for the configured display zone.
timezone_offset: i32,
} }
impl<B: JmapBackend> MetadataWorker<B> { impl<B: JmapBackend> MetadataWorker<B> {
@ -164,6 +166,7 @@ impl<B: JmapBackend> MetadataWorker<B> {
chat_id: i64, chat_id: i64,
state: Arc<dyn ReliableState>, state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>, llm: Option<Arc<LlmClient>>,
timezone_offset: i32,
) -> Self { ) -> Self {
Self { Self {
jmap, jmap,
@ -171,10 +174,27 @@ impl<B: JmapBackend> MetadataWorker<B> {
chat_id, chat_id,
state, state,
llm, 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<String> {
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] #[async_trait]
impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> { impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), ()> { async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), ()> {
@ -236,7 +256,7 @@ impl<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()), subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()),
received_at: metadata received_at: metadata
.received_at .received_at
.map(|v| v.to_string()) .and_then(|unix| format_received_at(unix, self.timezone_offset))
.unwrap_or_else(|| "unknown".into()), .unwrap_or_else(|| "unknown".into()),
}, },
) )
@ -996,6 +1016,7 @@ mod reload_tests {
llm_model: None, llm_model: None,
reconcile_token: SecretString::new("reconcile".into()), reconcile_token: SecretString::new("reconcile".into()),
worker_token: SecretString::new("worker".into()), worker_token: SecretString::new("worker".into()),
timezone: "Asia/Shanghai".into(),
} }
} }
@ -1042,4 +1063,44 @@ mod reload_tests {
Ok(()) 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);
}
} }