use crate::{App, db}; use serde_json::json; use std::sync::Arc; use std::time::Duration; /// Fire-and-forget notification to the configured webhook (if any). pub fn notify(app: Arc, title: String, body: String, good: bool) { tokio::spawn(async move { let configured = { let c = app.db.lock().unwrap(); !db::setting_get(&c, "webhook_url").is_empty() }; if configured { if let Err(e) = send(&app, &title, &body, good).await { eprintln!("webhook failed: {e}"); } } }); } pub async fn send(app: &App, title: &str, body: &str, good: bool) -> Result<(), String> { let (url, kind) = { let c = app.db.lock().unwrap(); (db::setting_get(&c, "webhook_url"), db::setting_get(&c, "webhook_kind")) }; if url.is_empty() { return Err("no webhook URL configured".into()); } let client = &app.http_strict; let req = match kind.as_str() { "discord" => client .post(&url) .json(&json!({ "content": format!("{} **{}**\n{}", if good { "🟢" } else { "🔴" }, title, body) })), "slack" => client .post(&url) .json(&json!({ "text": format!("{} *{}*\n{}", if good { ":large_green_circle:" } else { ":red_circle:" }, title, body) })), "ntfy" => { let mut r = client .post(&url) .header("Tags", if good { "white_check_mark" } else { "rotating_light" }) .header("Priority", if good { "default" } else { "high" }) .body(body.to_string()); if title.is_ascii() && !title.chars().any(|c| c.is_control()) { r = r.header("Title", title); } else { r = r.body(format!("{title}\n{body}")); } r } _ => client.post(&url).json(&json!({ "title": title, "message": body, "status": if good { "up" } else { "down" }, "time": db::now(), "source": "labdeck", })), }; let resp = req .timeout(Duration::from_secs(10)) .send() .await .map_err(|e| e.to_string())?; if resp.status().is_success() { Ok(()) } else { Err(format!("webhook answered HTTP {}", resp.status().as_u16())) } }