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:
parent
5e32fe842a
commit
d32d645322
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
216
src/config.rs
216
src/config.rs
|
|
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue