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}