Skip to main content

ryu_notify/
lib.rs

1//! Shared notification-delivery wire types + send primitives.
2//!
3//! This crate is the narrow surface that both **Core** (the kernel notification
4//! store + fan-out orchestration in `apps/core/src/notify`) and the
5//! out-of-process **monitors** engine (`apps-store/monitors/backend`) need in
6//! common: the channel-target enum and the dep-light HTTP send functions. It has
7//! ZERO dependency on `apps/core`.
8//!
9//! Placement (Core vs Gateway): a notification decides *what runs* (open a
10//! delivery socket) → Core-side. Nothing here is policy. The store,
11//! `deliver_user_notification`, dedupe, and the tiered `notify_all` fan-out that
12//! wires in the desktop event bus / plugin hooks / BYO SMTP live in Core; only the
13//! shared types + primitives live here.
14//!
15//! The set of targets is an extensible enum — the "nothing hardcoded, everything
16//! swappable" rule applied to channels: a webhook covers Slack/Discord/any HTTP
17//! endpoint, Telegram is a direct Bot-API send, Expo handles mobile, and Email
18//! carries only the recipient (the SMTP transport is a shared node resource
19//! resolved once at the Core call site, never stored per-target).
20//!
21//! Every send is best-effort unless its name ends in `_text`: those return
22//! `Ok(())` only on a 2xx so a workflow node can surface a failed delivery.
23
24use serde::{Deserialize, Serialize};
25use serde_json::json;
26
27const EXPO_PUSH_URL: &str = "https://exp.host/--/api/v2/push/send";
28
29/// A notification destination (per-monitor or node-level policy-alert channel).
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(tag = "kind", rename_all = "snake_case")]
32pub enum NotifyTarget {
33    /// Generic JSON POST. Works with Slack/Discord *incoming webhooks* and any
34    /// HTTP endpoint. We send both a Slack/Discord-friendly `text`/`content`
35    /// field and the structured alert so one URL fits most services.
36    Webhook { url: String },
37    /// Direct Telegram Bot API send (`sendMessage`).
38    Telegram { bot_token: String, chat_id: String },
39    /// A specific Expo push token (in addition to globally-registered devices).
40    ExpoPush { token: String },
41    /// A single email recipient. Unlike the self-contained Webhook/Telegram
42    /// targets, this carries the recipient ONLY: the SMTP transport is a shared
43    /// node resource resolved once at the Core call site (`ryu_email_send`), not
44    /// stored per-target, so the plaintext-secret surface is not multiplied.
45    Email { to: String },
46}
47
48/// Node-level alert delivery targets (self-host): the fan-out channels
49/// (webhook / Telegram / Expo push) + email recipients that policy alerts
50/// deliver to. Distinct from per-monitor targets, which are scoped to one
51/// watched site. Persisted in the Core notify store (`alert_delivery` table).
52#[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
60// ---- 2xx-gated primitives (workflow ChannelSend surfaces failures) ---------
61
62/// Post a plain-text message to a Slack/Discord/generic incoming webhook. Sends
63/// both `text` (Slack) and `content` (Discord) so one URL fits either service.
64/// Returns `Ok(())` only on a 2xx response.
65pub 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
86/// Send a plain-text message via the Telegram Bot API (`sendMessage`). Returns
87/// `Ok(())` only on a 2xx so a workflow node can surface a failed send.
88pub 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
110// ---- best-effort alert sends (fan-out; errors are logged, never propagated) --
111
112/// Best-effort webhook alert send: `{text, content, alert}` so both Slack/Discord
113/// framing and the structured payload ride one URL. `alert` is the full JSON
114/// carrier (embedded under `"alert"`).
115pub 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
138/// Best-effort Telegram alert send (with a bell emoji prefix).
139pub 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
159/// Send a plain title/body push to a set of Expo tokens. Best-effort: a failure
160/// is logged, never propagated. `data` rides through to the device payload.
161pub 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    // ---- serde: wire shape of the target enum -----------------------------
202
203    #[test]
204    fn notify_target_tag_is_snake_case_kind() {
205        // The `#[serde(tag = "kind", rename_all = "snake_case")]` contract is
206        // what the Core store + monitors engine persist and exchange; a silent
207        // rename would break every stored channel target.
208        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            // Round-trips back to an identical value.
243            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        // Both fields are `#[serde(default)]`: an empty object and a partial
277        // object must both parse, so an older stored row without one field
278        // still loads.
279        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    // ---- HTTP test harness -------------------------------------------------
301
302    #[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    /// Spawn a loopback server on an ephemeral port that records every request
324    /// and answers with `status`. Mirrors `crates/core/downloads`'s test idiom.
325    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    /// A bound-then-freed local address: connecting to it yields an immediate
341    /// connection-refused (no network, no DNS), which drives the send-failure
342    /// error branches deterministically.
343    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    // ---- send_webhook_text: full 2xx-gate coverage ------------------------
351
352    #[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        // Both a Slack `text` and a Discord `content` field carry the message.
364        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    // ---- send_telegram_text: error branch (https URL is hardcoded) ---------
389
390    #[tokio::test]
391    async fn telegram_text_err_on_connection_refused() {
392        // `.resolve` pins api.telegram.org to a dead local port: no DNS, no
393        // network, connection-refused before TLS. (The 2xx/else status branch
394        // needs a real TLS response — see the crate-level test notes.)
395        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    // ---- best-effort alert sends: shape + non-panic on failure ------------
409
410    #[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        // Non-2xx and connection-refused must both be swallowed (no panic, no
428        // return value) — fan-out never fails a caller.
429        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        // Must not panic even though the send fails.
449        send_telegram_alert(&http, "BOT", "CHAT", "Title", "body").await;
450    }
451
452    // ---- push_expo_message: empty short-circuit + failure path -------------
453
454    #[tokio::test]
455    async fn expo_push_empty_tokens_makes_no_request() {
456        let (addr, recorded) = spawn_server(StatusCode::OK).await;
457        // Pin exp.host at the recording server so a stray request WOULD be
458        // recorded; the empty-token early return means it never is.
459        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        // Non-empty tokens build the message batch and POST; a refused
477        // connection is swallowed (best-effort), no panic.
478        push_expo_message(
479            &http,
480            &["ExponentPushToken[abc]".to_string()],
481            "Title",
482            "Body",
483            json!({ "url": "/x" }),
484        )
485        .await;
486    }
487}