use anyhow::{Context, Result}; use reqwest::Client; use serde::Serialize; const PUSHBULLET_API_URL: &str = "https://api.pushbullet.com/v2/pushes"; #[derive(Debug, Serialize)] struct NotePush { #[serde(rename = "type")] push_type: String, title: String, body: String, } #[derive(Debug, Serialize)] struct LinkPush { #[serde(rename = "type")] push_type: String, title: String, body: String, url: String, } /// Pushbullet notification service. /// /// Sends push notifications to server administrators via the Pushbullet /// API. Used as a secondary notification channel alongside Discord for /// critical alerts that need to reach admins on their mobile devices. pub struct PushbulletNotifier { http: Client, api_key: String, } impl PushbulletNotifier { pub fn new(api_key: String) -> Self { Self { http: Client::new(), api_key, } } /// Send a text notification (note type push). pub async fn send_notification(&self, title: &str, body: &str) -> Result<()> { let payload = NotePush { push_type: "note".to_string(), title: title.to_string(), body: body.to_string(), }; let response = self .http .post(PUSHBULLET_API_URL) .header("Access-Token", &self.api_key) .json(&payload) .send() .await .context("Failed to send Pushbullet notification")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); tracing::error!("Pushbullet push failed: {} — {}", status, body); anyhow::bail!("Pushbullet returned {status}"); } Ok(()) } /// Send a link notification with a clickable URL. pub async fn send_link(&self, title: &str, body: &str, url: &str) -> Result<()> { let payload = LinkPush { push_type: "link".to_string(), title: title.to_string(), body: body.to_string(), url: url.to_string(), }; let response = self .http .post(PUSHBULLET_API_URL) .header("Access-Token", &self.api_key) .json(&payload) .send() .await .context("Failed to send Pushbullet link push")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); tracing::error!("Pushbullet push failed: {} — {}", status, body); anyhow::bail!("Pushbullet returned {status}"); } Ok(()) } /// Send a wipe-starting notification. pub async fn send_wipe_start(&self, server_name: &str, wipe_type: &str) -> Result<()> { self.send_notification( &format!("🔄 {} — Wipe Starting", server_name), &format!("{} wipe is beginning. Server will be offline briefly.", wipe_type), ) .await } /// Send a wipe-completed notification. pub async fn send_wipe_complete(&self, server_name: &str, wipe_type: &str) -> Result<()> { self.send_notification( &format!("✅ {} — Wipe Complete", server_name), &format!("{} wipe completed. Server is back online.", wipe_type), ) .await } /// Send a wipe-failed notification. pub async fn send_wipe_failed(&self, server_name: &str, error: &str) -> Result<()> { self.send_notification( &format!("❌ {} — Wipe Failed", server_name), &format!("Wipe failed: {}", error), ) .await } /// Send a crash alert. pub async fn send_crash_alert( &self, server_name: &str, crash_count: u32, auto_recovered: bool, ) -> Result<()> { let title = if auto_recovered { format!("⚠️ {} — Crash Recovered", server_name) } else { format!("🔴 {} — Crash — Manual Action Needed", server_name) }; let body = if auto_recovered { format!("Server crashed and was auto-restarted (attempt {}).", crash_count) } else { format!( "Server crashed {} times. Auto-recovery exhausted. Check the server.", crash_count ) }; self.send_notification(&title, &body).await } }