Skip to main content

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, &notification.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, &notification);
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}