bamboo_server/notify_sinks/mod.rs
1//! Delivery sinks for classified notifications.
2//!
3//! `bamboo_notification::NotificationService::notify` (backend policy) and
4//! [`crate::app_state::session_events`] already decide WHETHER an event is
5//! notification-worthy and materialize it as an `AgentEvent::Notification`
6//! for SSE/WS clients. This module is the next stage: WHERE else to deliver
7//! that notification — an OS-native popup ([`desktop`]) and/or push-relay
8//! services ([`ntfy`], [`bark`]).
9//!
10//! Sinks are constructed fresh from the CURRENT config on every dispatch (see
11//! [`dispatch_to_sinks`]) rather than once at startup — config is
12//! hot-reloadable, and a sink built once would silently keep serving a stale
13//! topic/token/toggle forever. Every sink's `deliver` is non-blocking and
14//! swallows its own errors (logged via `tracing::warn`/`debug`): a failed
15//! notification must never break an agent run.
16
17mod bark;
18mod desktop;
19mod ntfy;
20
21pub use desktop::{is_sidecar_mode, set_sidecar_mode};
22
23use bamboo_llm::Config;
24
25/// An owned, sink-agnostic notification payload.
26///
27/// Deliberately decoupled from `AgentEvent::Notification` (whose fields are
28/// wire-shaped for the SSE/WS client, e.g. carrying a dedup key and a
29/// timestamp sinks have no use for) — see [`SinkNotification::from_event`].
30#[derive(Debug, Clone, PartialEq)]
31pub struct SinkNotification {
32 pub title: String,
33 pub body: String,
34 /// Stable wire category, e.g. `"needs_approval"`, `"run_completed"` (see
35 /// `bamboo_notification::policy::NotificationCategory::as_str`).
36 pub category: String,
37 /// `"high"` | `"normal"` | `"low"` (see
38 /// `bamboo_notification::policy::NotificationPriority::as_str`).
39 pub priority: String,
40 pub session_id: String,
41 /// Deep link to surface in a click-through push notification. Not yet
42 /// populated by any producer — reserved for a future frontend route.
43 pub click_url: Option<String>,
44}
45
46impl SinkNotification {
47 /// Builds a sink-facing payload from an already-classified
48 /// `AgentEvent::Notification`. Returns `None` for any other event
49 /// variant (defensive; callers should only ever pass a `Notification`).
50 pub fn from_event(event: &bamboo_agent_core::AgentEvent) -> Option<Self> {
51 match event {
52 bamboo_agent_core::AgentEvent::Notification {
53 session_id,
54 category,
55 priority,
56 title,
57 body,
58 ..
59 } => Some(Self {
60 title: title.clone(),
61 body: body.clone(),
62 category: category.clone(),
63 priority: priority.clone(),
64 session_id: session_id.clone(),
65 click_url: None,
66 }),
67 _ => None,
68 }
69 }
70}
71
72/// A delivery target for a classified notification.
73///
74/// `deliver` is deliberately **not** `async`: implementations spawn their own
75/// tokio task so a slow/unreachable sink (a hung ntfy server, a wedged D-Bus
76/// session) can never stall the caller — the always-on notification relay
77/// loop that classifies every session's events.
78pub trait NotificationSink: Send + Sync {
79 /// Short identifier for logging (e.g. `"desktop"`, `"ntfy"`, `"bark"`).
80 #[allow(dead_code)] // surfaced for future structured logging / metrics.
81 fn name(&self) -> &'static str;
82
83 /// Delivers `n`. Non-blocking: returns immediately, doing the actual I/O
84 /// (D-Bus/mac call, HTTP POST) on a spawned task.
85 fn deliver(&self, n: &SinkNotification);
86}
87
88/// Channel names [`dispatch_to_sinks`] would attempt delivery through, given
89/// `config`/`has_watcher`/`category` — the exact same gating `dispatch_to_sinks`
90/// applies (desktop's auto-vs-sidecar default + explicit override + watcher
91/// suppression; ntfy/bark's plain `enabled` flag). Factored out so the two
92/// never drift apart: [`dispatch_to_sinks`] delivers through this list, and
93/// the `POST /notifications/test` handler (`handlers::agent::notifications`)
94/// reports it back to the caller so a setup UI can show which channels were
95/// actually exercised.
96pub fn attempted_channels(config: &Config, has_watcher: bool, category: &str) -> Vec<&'static str> {
97 let mut channels = Vec::new();
98 if desktop::desktop_enabled(
99 config.notifications.desktop.enabled,
100 desktop::is_sidecar_mode(),
101 ) && !desktop::suppressed_by_watcher(category, has_watcher)
102 {
103 channels.push("desktop");
104 }
105 if config.notifications.ntfy.enabled {
106 channels.push("ntfy");
107 }
108 if config.notifications.bark.enabled {
109 channels.push("bark");
110 }
111 channels
112}
113
114/// Fans a classified notification out to every enabled/gated sink, reading
115/// `config` (a snapshot the caller just took — see
116/// `app_state::session_events::ensure_notification_relay`) so a hot-reloaded
117/// topic/token/toggle takes effect on the very next notification.
118///
119/// `has_watcher` is whether the session currently has ≥1 live SSE/WS client
120/// (`app_state::watchers::SessionWatchers`, NOT `broadcast::receiver_count` —
121/// that count is inflated by the relay's own subscription). It only
122/// suppresses the desktop popup for categories the UI already surfaces
123/// (`run_completed`, `needs_*`) — push sinks (ntfy/bark) are never suppressed
124/// by a watcher: a phone push is still useful while the desktop UI is open.
125pub fn dispatch_to_sinks(config: &Config, has_watcher: bool, notification: &SinkNotification) {
126 for channel in attempted_channels(config, has_watcher, ¬ification.category) {
127 match channel {
128 "desktop" => desktop::DesktopSink.deliver(notification),
129 "ntfy" => ntfy::NtfySink::from_config(&config.notifications.ntfy).deliver(notification),
130 "bark" => bark::BarkSink::from_config(&config.notifications.bark).deliver(notification),
131 _ => unreachable!("attempted_channels only ever returns known channel names"),
132 }
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 fn sample() -> bamboo_agent_core::AgentEvent {
141 bamboo_agent_core::AgentEvent::Notification {
142 id: "n1".to_string(),
143 session_id: "sess-1".to_string(),
144 category: "needs_approval".to_string(),
145 priority: "high".to_string(),
146 title: "Approval needed".to_string(),
147 body: "run `rm -rf`?".to_string(),
148 dedup_key: Some("dk".to_string()),
149 created_at: "2026-01-01T00:00:00Z".to_string(),
150 }
151 }
152
153 #[test]
154 fn from_event_extracts_sink_fields() {
155 let sink = SinkNotification::from_event(&sample()).unwrap();
156 assert_eq!(sink.title, "Approval needed");
157 assert_eq!(sink.body, "run `rm -rf`?");
158 assert_eq!(sink.category, "needs_approval");
159 assert_eq!(sink.priority, "high");
160 assert_eq!(sink.session_id, "sess-1");
161 assert_eq!(sink.click_url, None);
162 }
163
164 #[test]
165 fn from_event_rejects_non_notification_variants() {
166 let event = bamboo_agent_core::AgentEvent::Token {
167 content: "hi".to_string(),
168 };
169 assert!(SinkNotification::from_event(&event).is_none());
170 }
171
172 #[test]
173 fn dispatch_to_sinks_does_not_panic_with_everything_disabled() {
174 // No enabled channels (desktop explicitly off, ntfy/bark default off)
175 // — must be a pure no-op, not a panic or a hang.
176 let mut config = Config::default();
177 config.notifications.desktop.enabled = Some(false);
178 let notification = SinkNotification::from_event(&sample()).unwrap();
179 dispatch_to_sinks(&config, false, ¬ification);
180 }
181
182 #[test]
183 fn attempted_channels_reports_none_when_everything_disabled() {
184 let mut config = Config::default();
185 config.notifications.desktop.enabled = Some(false);
186 assert!(attempted_channels(&config, false, "custom").is_empty());
187 }
188
189 #[test]
190 fn attempted_channels_reports_every_enabled_channel_in_order() {
191 let mut config = Config::default();
192 config.notifications.desktop.enabled = Some(true);
193 config.notifications.ntfy.enabled = true;
194 config.notifications.bark.enabled = true;
195 assert_eq!(
196 attempted_channels(&config, false, "custom"),
197 vec!["desktop", "ntfy", "bark"]
198 );
199 }
200
201 #[test]
202 fn attempted_channels_omits_desktop_when_suppressed_by_watcher() {
203 let mut config = Config::default();
204 config.notifications.desktop.enabled = Some(true);
205 config.notifications.ntfy.enabled = true;
206 // run_completed + a live watcher suppresses ONLY the desktop popup.
207 assert_eq!(
208 attempted_channels(&config, true, "run_completed"),
209 vec!["ntfy"]
210 );
211 }
212
213 #[test]
214 fn attempted_channels_matches_what_dispatch_to_sinks_would_attempt() {
215 // A lightweight cross-check that the two never drift: every channel
216 // `attempted_channels` reports is one `dispatch_to_sinks` actually has
217 // gating logic for (desktop/ntfy/bark), for a representative matrix.
218 let matrices = [
219 (Some(true), true, true),
220 (Some(false), false, true),
221 (None, true, false),
222 ];
223 for (desktop_enabled, ntfy_enabled, bark_enabled) in matrices {
224 let mut config = Config::default();
225 config.notifications.desktop.enabled = desktop_enabled;
226 config.notifications.ntfy.enabled = ntfy_enabled;
227 config.notifications.bark.enabled = bark_enabled;
228 let names = attempted_channels(&config, false, "custom");
229 assert!(names
230 .iter()
231 .all(|c| ["desktop", "ntfy", "bark"].contains(c)));
232 }
233 }
234}