358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
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<Mutex<HashMap<String, i64>>> = 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<i64>,
|
|
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<i64>,
|
|
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()),
|
|
}
|
|
}
|