use crate::{models::NotificationSettings, state::AppState}; use chrono::Utc; use serde_json::{json, Value}; use std::{ collections::HashMap, sync::{Mutex, OnceLock}, }; static LAST_SENT: OnceLock>> = OnceLock::new(); fn important_kind(kind: &str) -> bool { matches!( kind, "device.offline" | "device.recovered" | "automation.fired" | "automation.error" | "zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" | "device.communication_error" ) } fn alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool { let types = &cfg.alert_types; if kind == "ha.sensor_stale" { return types.stale_sensor; } if kind.starts_with("ha.sensor_") { return types.sensor_errors; } if matches!( kind, "device.offline" | "device.communication_error" | "device.command_unconfirmed" ) { return types.communication; } if kind == "zone.target_timeout" { return types.target_timeout; } if kind.starts_with("automation.") { return types.automation; } if kind == "zone.sensor_discrepancy" { return types.sensor_discrepancy; } if matches!( kind, "zone.action_error" | "zone.mode_change_off_error" | "zone.local_power_error" | "zone.device_missing" | "zone.temporary_quick_thermostat_cancelled" | "zone.temporary_quick_thermostat_poweroff_error" | "group.power_error" ) { return types.control_errors; } if important_kind(kind) { return types.important_events; } types.other } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DeliveryDecision { NotApplicable, Silent(&'static str), Send, } fn delivery_decision(cfg: &NotificationSettings, level: &str, kind: &str) -> DeliveryDecision { let candidate = level == "error" || level == "warn" || important_kind(kind); if !candidate { return DeliveryDecision::NotApplicable; } if !cfg.enabled { return DeliveryDecision::Silent("notifications_disabled"); } if !alert_type_enabled(cfg, kind) { return DeliveryDecision::Silent("alert_type_disabled"); } if level != "error" && level != "warn" && cfg.mode != "important" { return DeliveryDecision::Silent("mode_filtered"); } DeliveryDecision::Send } fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String { let entity = metadata .get("device_id") .or_else(|| metadata.get("zone_id")) .or_else(|| metadata.get("automation_id")) .and_then(Value::as_str) .unwrap_or("global"); format!("{}:{}:{}", cfg.provider, kind, entity) } fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bool { let now = Utc::now().timestamp(); let key = cooldown_key(cfg, kind, metadata); let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new())); let Ok(mut map) = map.lock() else { return false; }; if let Some(last) = map.get(&key) { if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; } } map.insert(key, now); true } fn with_notification_status( metadata: &Value, status: &str, reason: Option<&str>, provider: &str, ) -> Value { let mut updated = match metadata { Value::Object(map) => Value::Object(map.clone()), other => json!({"data": other}), }; let mut notification = serde_json::Map::new(); notification.insert("status".into(), Value::String(status.into())); if let Some(reason) = reason { notification.insert("reason".into(), Value::String(reason.into())); } if !provider.trim().is_empty() { notification.insert("provider".into(), Value::String(provider.into())); } if let Some(object) = updated.as_object_mut() { object.insert("notification".into(), Value::Object(notification)); } updated } fn persist_notification_status( state: &AppState, event_id: Option, metadata: &Value, status: &str, reason: Option<&str>, provider: &str, ) { let Some(event_id) = event_id else { return; }; let updated = with_notification_status(metadata, status, reason, provider); match state.db.update_event_metadata(event_id, &updated) { Ok(true) => state.broadcast("log.updated", json!({"id": event_id, "metadata": updated})), Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"), Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"), } } pub async fn dispatch( state: AppState, event_id: Option, level: String, kind: String, message: String, metadata: Value, ) { let cfg = state.settings.read().await.notifications.clone(); match delivery_decision(&cfg, &level, &kind) { DeliveryDecision::NotApplicable => return, DeliveryDecision::Silent(reason) => { persist_notification_status( &state, event_id, &metadata, "silent", Some(reason), &cfg.provider, ); return; } DeliveryDecision::Send => {} } if !take_cooldown(&cfg, &kind, &metadata) { persist_notification_status( &state, event_id, &metadata, "silent", Some("cooldown"), &cfg.provider, ); return; } let title = format!( "GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" } ); let result = match cfg.provider.as_str() { "pushover" => send_pushover(&state, &cfg, &title, &message).await, "slack" => { send_webhook( &cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)}), ) .await } "discord" => { send_webhook( &cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)}), ) .await } _ => Err("unsupported notification provider".into()), }; match result { Ok(()) => { persist_notification_status(&state, event_id, &metadata, "sent", None, &cfg.provider) } Err(err) => { persist_notification_status( &state, event_id, &metadata, "failed", Some("delivery_failed"), &cfg.provider, ); tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed"); } } } #[cfg(test)] mod tests { use super::*; #[test] fn sensor_discrepancy_has_its_own_notification_toggle() { let mut cfg = NotificationSettings::default(); cfg.alert_types.control_errors = true; cfg.alert_types.sensor_discrepancy = false; assert!(!alert_type_enabled(&cfg, "zone.sensor_discrepancy")); assert!(alert_type_enabled(&cfg, "zone.action_error")); cfg.alert_types.control_errors = false; cfg.alert_types.sensor_discrepancy = true; assert!(alert_type_enabled(&cfg, "zone.sensor_discrepancy")); assert!(!alert_type_enabled(&cfg, "zone.action_error")); } #[test] fn disabled_alert_type_is_recorded_as_silent() { let mut cfg = NotificationSettings::default(); cfg.enabled = true; cfg.mode = "important".into(); cfg.alert_types.sensor_discrepancy = false; assert_eq!( delivery_decision(&cfg, "warn", "zone.sensor_discrepancy"), DeliveryDecision::Silent("alert_type_disabled") ); } #[test] fn ordinary_info_event_has_no_notification_status() { let cfg = NotificationSettings::default(); assert_eq!( delivery_decision(&cfg, "info", "zone.quick_control"), DeliveryDecision::NotApplicable ); } } async fn send_pushover( state: &AppState, cfg: &NotificationSettings, title: &str, message: &str, ) -> Result<(), String> { if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); } let response = state .http .post("https://api.pushover.net/1/messages.json") .form(&[ ("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message), ]) .send() .await .map_err(|e| e.to_string())?; if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) } } async fn send_webhook(url: &str, body: Value) -> Result<(), String> { let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?; if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com"; if !allowed { return Err("webhook host is not supported".into()); } let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; let response = client .post(parsed) .json(&body) .send() .await .map_err(|e| e.to_string())?; if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) } } pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> { match cfg.provider.as_str() { "pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await, "slack" => { send_webhook( &cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"}), ) .await } "discord" => { send_webhook( &cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"}), ) .await } _ => Err("unsupported notification provider".into()), } }