v0.12.0-preety_code
This commit is contained in:
+162
-42
@@ -1,43 +1,84 @@
|
||||
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
|
||||
use crate::{models::NotificationSettings, state::AppState};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use crate::{models::NotificationSettings, state::AppState};
|
||||
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")
|
||||
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 matches!(kind,
|
||||
"zone.action_error" | "zone.mode_change_off_error" | "zone.local_power_error" |
|
||||
"zone.device_missing" | "zone.sensor_discrepancy" |
|
||||
"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; }
|
||||
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 matches!(
|
||||
kind,
|
||||
"zone.action_error"
|
||||
| "zone.mode_change_off_error"
|
||||
| "zone.local_power_error"
|
||||
| "zone.device_missing"
|
||||
| "zone.sensor_discrepancy"
|
||||
| "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
|
||||
}
|
||||
|
||||
fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool {
|
||||
if !cfg.enabled || !alert_type_enabled(cfg, kind) { return false; }
|
||||
if level == "error" || level == "warn" { return true; }
|
||||
if !cfg.enabled || !alert_type_enabled(cfg, kind) {
|
||||
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");
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -45,22 +86,55 @@ fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bo
|
||||
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; };
|
||||
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; }
|
||||
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) {
|
||||
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" });
|
||||
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(&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,
|
||||
"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()),
|
||||
};
|
||||
if let Err(err) = result {
|
||||
@@ -68,33 +142,79 @@ pub async fn dispatch(state: AppState, level: String, kind: String, message: Str
|
||||
}
|
||||
}
|
||||
|
||||
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_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()); }
|
||||
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()); }
|
||||
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())) }
|
||||
.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,
|
||||
"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()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user