This commit is contained in:
Mateusz Gruszczyński
2026-08-24 23:11:41 +02:00
parent 4ab878d587
commit 76a6f050ae
24 changed files with 600 additions and 108 deletions
+83
View File
@@ -0,0 +1,83 @@
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
use chrono::Utc;
use serde_json::{json, Value};
use crate::{models::NotificationSettings, state::AppState};
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 should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
if !cfg.enabled { return false; }
if level == "error" || level == "warn" { return true; }
cfg.mode == "important" && important_kind(kind)
}
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
}
pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) {
let cfg = state.settings.read().await.notifications.clone();
if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { 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(&state, &cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await,
"discord" => send_webhook(&state, &cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await,
_ => Err("unsupported notification provider".into()),
};
if let Err(err) = result {
tracing::warn!(provider=%cfg.provider, error=%err, "notification delivery failed");
}
}
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(state: &AppState, 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(state, &cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await,
"discord" => send_webhook(state, &cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await,
_ => Err("unsupported notification provider".into()),
}
}