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}