1use serde::{Deserialize, Serialize};
25use serde_json::json;
26
27const EXPO_PUSH_URL: &str = "https://exp.host/--/api/v2/push/send";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "kind", rename_all = "snake_case")]
32pub enum NotifyTarget {
33 Webhook { url: String },
37 Telegram { bot_token: String, chat_id: String },
39 ExpoPush { token: String },
41 Email { to: String },
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct AlertDeliveryTargets {
54 #[serde(default)]
55 pub targets: Vec<NotifyTarget>,
56 #[serde(default)]
57 pub emails: Vec<String>,
58}
59
60pub async fn send_webhook_text(
66 http: &reqwest::Client,
67 url: &str,
68 text: &str,
69) -> Result<(), String> {
70 let body = json!({ "text": text, "content": text });
71 let resp = http
72 .post(url)
73 .json(&body)
74 .timeout(std::time::Duration::from_secs(15))
75 .send()
76 .await
77 .map_err(|e| format!("webhook send failed: {e}"))?;
78 let status = resp.status();
79 if status.is_success() {
80 Ok(())
81 } else {
82 Err(format!("webhook returned HTTP {status}"))
83 }
84}
85
86pub async fn send_telegram_text(
89 http: &reqwest::Client,
90 bot_token: &str,
91 chat_id: &str,
92 text: &str,
93) -> Result<(), String> {
94 let api = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
95 let resp = http
96 .post(&api)
97 .json(&json!({ "chat_id": chat_id, "text": text }))
98 .timeout(std::time::Duration::from_secs(15))
99 .send()
100 .await
101 .map_err(|e| format!("telegram send failed: {e}"))?;
102 let status = resp.status();
103 if status.is_success() {
104 Ok(())
105 } else {
106 Err(format!("telegram returned HTTP {status}"))
107 }
108}
109
110pub async fn send_webhook_alert(
116 http: &reqwest::Client,
117 url: &str,
118 title: &str,
119 message: &str,
120 alert: &serde_json::Value,
121) {
122 let body = json!({
123 "text": format!("{title}\n{message}"),
124 "content": format!("{title}\n{message}"),
125 "alert": alert,
126 });
127 let result = http
128 .post(url)
129 .json(&body)
130 .timeout(std::time::Duration::from_secs(15))
131 .send()
132 .await;
133 if let Err(e) = result {
134 tracing::warn!("notify: webhook to {url} failed: {e}");
135 }
136}
137
138pub async fn send_telegram_alert(
140 http: &reqwest::Client,
141 bot_token: &str,
142 chat_id: &str,
143 title: &str,
144 message: &str,
145) {
146 let api = format!("https://api.telegram.org/bot{bot_token}/sendMessage");
147 let text = format!("\u{1f514} {title}\n{message}");
148 let result = http
149 .post(&api)
150 .json(&json!({ "chat_id": chat_id, "text": text }))
151 .timeout(std::time::Duration::from_secs(15))
152 .send()
153 .await;
154 if let Err(e) = result {
155 tracing::warn!("notify: telegram alert failed: {e}");
156 }
157}
158
159pub async fn push_expo_message(
162 http: &reqwest::Client,
163 tokens: &[String],
164 title: &str,
165 body: &str,
166 data: serde_json::Value,
167) {
168 if tokens.is_empty() {
169 return;
170 }
171 let messages: Vec<_> = tokens
172 .iter()
173 .map(|t| {
174 json!({
175 "to": t,
176 "title": title,
177 "body": body,
178 "sound": "default",
179 "data": data,
180 })
181 })
182 .collect();
183 let result = http
184 .post(EXPO_PUSH_URL)
185 .json(&messages)
186 .timeout(std::time::Duration::from_secs(15))
187 .send()
188 .await;
189 if let Err(e) = result {
190 tracing::warn!("notify: expo push message failed: {e}");
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use axum::{body::Bytes, extract::State, http::StatusCode, http::Uri, Router};
198 use std::net::SocketAddr;
199 use std::sync::{Arc, Mutex};
200
201 #[test]
204 fn notify_target_tag_is_snake_case_kind() {
205 let cases = [
209 (
210 NotifyTarget::Webhook {
211 url: "https://hooks.example/x".into(),
212 },
213 "webhook",
214 ),
215 (
216 NotifyTarget::Telegram {
217 bot_token: "abc".into(),
218 chat_id: "42".into(),
219 },
220 "telegram",
221 ),
222 (
223 NotifyTarget::ExpoPush {
224 token: "ExponentPushToken[y]".into(),
225 },
226 "expo_push",
227 ),
228 (
229 NotifyTarget::Email {
230 to: "a@b.co".into(),
231 },
232 "email",
233 ),
234 ];
235 for (target, expected_kind) in cases {
236 let v = serde_json::to_value(&target).unwrap();
237 assert_eq!(
238 v.get("kind").and_then(|k| k.as_str()),
239 Some(expected_kind),
240 "wrong kind tag for {target:?}"
241 );
242 let back: NotifyTarget = serde_json::from_value(v).unwrap();
244 assert_eq!(back, target);
245 }
246 }
247
248 #[test]
249 fn notify_target_deserializes_from_tagged_json() {
250 let t: NotifyTarget =
251 serde_json::from_str(r#"{"kind":"telegram","bot_token":"T","chat_id":"C"}"#).unwrap();
252 assert_eq!(
253 t,
254 NotifyTarget::Telegram {
255 bot_token: "T".into(),
256 chat_id: "C".into(),
257 }
258 );
259 }
260
261 #[test]
262 fn notify_target_unknown_kind_is_rejected() {
263 let r: Result<NotifyTarget, _> = serde_json::from_str(r#"{"kind":"carrier_pigeon"}"#);
264 assert!(r.is_err(), "unknown channel kind must not deserialize");
265 }
266
267 #[test]
268 fn alert_delivery_targets_default_is_empty() {
269 let d = AlertDeliveryTargets::default();
270 assert!(d.targets.is_empty());
271 assert!(d.emails.is_empty());
272 }
273
274 #[test]
275 fn alert_delivery_targets_fills_missing_fields() {
276 let empty: AlertDeliveryTargets = serde_json::from_str("{}").unwrap();
280 assert!(empty.targets.is_empty() && empty.emails.is_empty());
281
282 let partial: AlertDeliveryTargets =
283 serde_json::from_str(r#"{"emails":["ops@x.io"]}"#).unwrap();
284 assert!(partial.targets.is_empty());
285 assert_eq!(partial.emails, vec!["ops@x.io".to_string()]);
286
287 let full: AlertDeliveryTargets = serde_json::from_str(
288 r#"{"targets":[{"kind":"webhook","url":"https://h/x"}],"emails":["a@b.co"]}"#,
289 )
290 .unwrap();
291 assert_eq!(full.targets.len(), 1);
292 assert_eq!(
293 full.targets[0],
294 NotifyTarget::Webhook {
295 url: "https://h/x".into()
296 }
297 );
298 }
299
300 #[derive(Clone)]
303 struct Recorded {
304 path: String,
305 body: serde_json::Value,
306 }
307
308 #[derive(Clone)]
309 struct AppState {
310 recorded: Arc<Mutex<Vec<Recorded>>>,
311 status: StatusCode,
312 }
313
314 async fn record_handler(State(st): State<AppState>, uri: Uri, body: Bytes) -> StatusCode {
315 let json = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
316 st.recorded.lock().unwrap().push(Recorded {
317 path: uri.path().to_string(),
318 body: json,
319 });
320 st.status
321 }
322
323 async fn spawn_server(status: StatusCode) -> (SocketAddr, Arc<Mutex<Vec<Recorded>>>) {
326 let recorded = Arc::new(Mutex::new(Vec::new()));
327 let state = AppState {
328 recorded: recorded.clone(),
329 status,
330 };
331 let app = Router::new().fallback(record_handler).with_state(state);
332 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
333 let addr = listener.local_addr().unwrap();
334 tokio::spawn(async move {
335 let _ = axum::serve(listener, app).await;
336 });
337 (addr, recorded)
338 }
339
340 fn dead_addr() -> SocketAddr {
344 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
345 let a = l.local_addr().unwrap();
346 drop(l);
347 a
348 }
349
350 #[tokio::test]
353 async fn webhook_text_ok_on_2xx_and_sends_text_and_content() {
354 let (addr, recorded) = spawn_server(StatusCode::OK).await;
355 let http = reqwest::Client::new();
356 let url = format!("http://{addr}/hook");
357 let out = send_webhook_text(&http, &url, "hello world").await;
358 assert!(out.is_ok(), "2xx must map to Ok: {out:?}");
359
360 let rec = recorded.lock().unwrap();
361 assert_eq!(rec.len(), 1);
362 assert_eq!(rec[0].path, "/hook");
363 assert_eq!(rec[0].body["text"], "hello world");
365 assert_eq!(rec[0].body["content"], "hello world");
366 }
367
368 #[tokio::test]
369 async fn webhook_text_err_on_non_2xx() {
370 let (addr, _rec) = spawn_server(StatusCode::INTERNAL_SERVER_ERROR).await;
371 let http = reqwest::Client::new();
372 let url = format!("http://{addr}/hook");
373 let err = send_webhook_text(&http, &url, "x").await.unwrap_err();
374 assert!(err.contains("HTTP 500"), "unexpected error: {err}");
375 }
376
377 #[tokio::test]
378 async fn webhook_text_err_on_connection_refused() {
379 let http = reqwest::Client::new();
380 let url = format!("http://{}/hook", dead_addr());
381 let err = send_webhook_text(&http, &url, "x").await.unwrap_err();
382 assert!(
383 err.contains("webhook send failed"),
384 "unexpected error: {err}"
385 );
386 }
387
388 #[tokio::test]
391 async fn telegram_text_err_on_connection_refused() {
392 let http = reqwest::Client::builder()
396 .resolve("api.telegram.org", dead_addr())
397 .build()
398 .unwrap();
399 let err = send_telegram_text(&http, "BOT", "CHAT", "hi")
400 .await
401 .unwrap_err();
402 assert!(
403 err.contains("telegram send failed"),
404 "unexpected error: {err}"
405 );
406 }
407
408 #[tokio::test]
411 async fn webhook_alert_posts_title_message_and_alert_payload() {
412 let (addr, recorded) = spawn_server(StatusCode::OK).await;
413 let http = reqwest::Client::new();
414 let url = format!("http://{addr}/hook");
415 let alert = json!({ "severity": "high", "id": 7 });
416 send_webhook_alert(&http, &url, "Down!", "site is 500ing", &alert).await;
417
418 let rec = recorded.lock().unwrap();
419 assert_eq!(rec.len(), 1);
420 assert_eq!(rec[0].body["text"], "Down!\nsite is 500ing");
421 assert_eq!(rec[0].body["content"], "Down!\nsite is 500ing");
422 assert_eq!(rec[0].body["alert"], alert);
423 }
424
425 #[tokio::test]
426 async fn webhook_alert_is_best_effort_on_failure() {
427 let (addr, _rec) = spawn_server(StatusCode::BAD_GATEWAY).await;
430 let http = reqwest::Client::new();
431 send_webhook_alert(&http, &format!("http://{addr}/hook"), "t", "m", &json!({})).await;
432 send_webhook_alert(
433 &http,
434 &format!("http://{}/hook", dead_addr()),
435 "t",
436 "m",
437 &json!({}),
438 )
439 .await;
440 }
441
442 #[tokio::test]
443 async fn telegram_alert_is_best_effort_on_failure() {
444 let http = reqwest::Client::builder()
445 .resolve("api.telegram.org", dead_addr())
446 .build()
447 .unwrap();
448 send_telegram_alert(&http, "BOT", "CHAT", "Title", "body").await;
450 }
451
452 #[tokio::test]
455 async fn expo_push_empty_tokens_makes_no_request() {
456 let (addr, recorded) = spawn_server(StatusCode::OK).await;
457 let http = reqwest::Client::builder()
460 .resolve("exp.host", addr)
461 .build()
462 .unwrap();
463 push_expo_message(&http, &[], "t", "b", json!({})).await;
464 assert!(
465 recorded.lock().unwrap().is_empty(),
466 "empty token list must short-circuit before any request"
467 );
468 }
469
470 #[tokio::test]
471 async fn expo_push_non_empty_is_best_effort_on_failure() {
472 let http = reqwest::Client::builder()
473 .resolve("exp.host", dead_addr())
474 .build()
475 .unwrap();
476 push_expo_message(
479 &http,
480 &["ExponentPushToken[abc]".to_string()],
481 "Title",
482 "Body",
483 json!({ "url": "/x" }),
484 )
485 .await;
486 }
487}