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 command;
19mod desktop;
20mod ntfy;
21
22pub use desktop::{is_sidecar_mode, set_sidecar_mode};
23
24use bamboo_llm::Config;
25
26/// An owned, sink-agnostic notification payload.
27///
28/// Decoupled from `AgentEvent::Notification` so producers can attach a sink-only
29/// click URL. Delivery metadata is retained because command hooks receive the
30/// complete classified notification — see [`SinkNotification::from_event`].
31#[derive(Debug, Clone, PartialEq)]
32pub struct SinkNotification {
33    pub id: Option<String>,
34    pub title: String,
35    pub body: String,
36    /// Stable wire category, e.g. `"needs_approval"`, `"run_completed"` (see
37    /// `bamboo_notification::policy::NotificationCategory::as_str`).
38    pub category: String,
39    /// `"high"` | `"normal"` | `"low"` (see
40    /// `bamboo_notification::policy::NotificationPriority::as_str`).
41    pub priority: String,
42    pub session_id: String,
43    pub dedup_key: Option<String>,
44    pub created_at: Option<String>,
45    /// Deep link to surface in a click-through push notification. The explicit
46    /// `Notify` tool populates this when its caller supplies a URL.
47    pub click_url: Option<String>,
48}
49
50impl SinkNotification {
51    /// Builds a sink-facing payload from an already-classified
52    /// `AgentEvent::Notification`. Returns `None` for any other event
53    /// variant (defensive; callers should only ever pass a `Notification`).
54    pub fn from_event(event: &bamboo_agent_core::AgentEvent) -> Option<Self> {
55        match event {
56            bamboo_agent_core::AgentEvent::Notification {
57                id,
58                session_id,
59                category,
60                priority,
61                title,
62                body,
63                dedup_key,
64                created_at,
65                ..
66            } => Some(Self {
67                id: Some(id.clone()),
68                title: title.clone(),
69                body: body.clone(),
70                category: category.clone(),
71                priority: priority.clone(),
72                session_id: session_id.clone(),
73                dedup_key: dedup_key.clone(),
74                created_at: Some(created_at.clone()),
75                click_url: None,
76            }),
77            _ => None,
78        }
79    }
80}
81
82/// A delivery target for a classified notification.
83///
84/// `deliver` is deliberately **not** `async`: implementations spawn their own
85/// tokio task so a slow/unreachable sink (a hung ntfy server, a wedged D-Bus
86/// session) can never stall the caller — the always-on notification relay
87/// loop that classifies every session's events.
88pub trait NotificationSink: Send + Sync {
89    /// Short identifier for logging (e.g. `"desktop"`, `"ntfy"`, `"bark"`).
90    #[allow(dead_code)] // surfaced for future structured logging / metrics.
91    fn name(&self) -> &'static str;
92
93    /// Delivers `n`. Non-blocking: returns immediately, doing the actual I/O
94    /// (D-Bus/mac call, HTTP POST) on a spawned task.
95    fn deliver(&self, n: &SinkNotification);
96}
97
98/// Channel names [`dispatch_to_sinks`] would attempt delivery through, given
99/// `config`/`has_watcher`/`category` — the exact same gating `dispatch_to_sinks`
100/// applies (desktop's auto-vs-sidecar default + explicit override + watcher
101/// suppression; ntfy/bark's plain `enabled` flag). Factored out so the two
102/// never drift apart: [`dispatch_to_sinks`] delivers through this list, and
103/// the `POST /notifications/test` handler (`handlers::agent::notifications`)
104/// reports it back to the caller so a setup UI can show which channels were
105/// actually exercised.
106pub fn attempted_channels(config: &Config, has_watcher: bool, category: &str) -> Vec<&'static str> {
107    let mut channels = Vec::new();
108    if config.lifecycle_hooks.enabled
109        && config
110            .lifecycle_hooks
111            .notification
112            .iter()
113            .any(|group| group.enabled && !group.hooks.is_empty())
114    {
115        channels.push("command");
116    }
117    if desktop::desktop_enabled(
118        config.notifications.desktop.enabled,
119        desktop::is_sidecar_mode(),
120    ) && !desktop::suppressed_by_watcher(category, has_watcher)
121    {
122        channels.push("desktop");
123    }
124    if config.notifications.ntfy.enabled {
125        channels.push("ntfy");
126    }
127    if config.notifications.bark.enabled {
128        channels.push("bark");
129    }
130    channels
131}
132
133/// Fans a classified notification out to every enabled/gated sink, reading
134/// `config` (a snapshot the caller just took — see
135/// `app_state::session_events::ensure_notification_relay`) so a hot-reloaded
136/// topic/token/toggle takes effect on the very next notification.
137///
138/// `has_watcher` is whether the session currently has ≥1 live SSE/WS client
139/// (`app_state::watchers::SessionWatchers`, NOT `broadcast::receiver_count` —
140/// that count is inflated by the relay's own subscription). It only
141/// suppresses the desktop popup for categories the UI already surfaces
142/// (`run_completed`, `needs_*`) — push sinks (ntfy/bark) are never suppressed
143/// by a watcher: a phone push is still useful while the desktop UI is open.
144pub fn dispatch_to_sinks(config: &Config, has_watcher: bool, notification: &SinkNotification) {
145    for channel in attempted_channels(config, has_watcher, &notification.category) {
146        match channel {
147            "command" => command::CommandSink::from_config(config).deliver(notification),
148            "desktop" => desktop::DesktopSink.deliver(notification),
149            "ntfy" => ntfy::NtfySink::from_config(&config.notifications.ntfy).deliver(notification),
150            "bark" => bark::BarkSink::from_config(&config.notifications.bark).deliver(notification),
151            _ => unreachable!("attempted_channels only ever returns known channel names"),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn sample() -> bamboo_agent_core::AgentEvent {
161        bamboo_agent_core::AgentEvent::Notification {
162            id: "n1".to_string(),
163            session_id: "sess-1".to_string(),
164            category: "needs_approval".to_string(),
165            priority: "high".to_string(),
166            title: "Approval needed".to_string(),
167            body: "run `rm -rf`?".to_string(),
168            dedup_key: Some("dk".to_string()),
169            created_at: "2026-01-01T00:00:00Z".to_string(),
170        }
171    }
172
173    #[test]
174    fn from_event_extracts_sink_fields() {
175        let sink = SinkNotification::from_event(&sample()).unwrap();
176        assert_eq!(sink.title, "Approval needed");
177        assert_eq!(sink.body, "run `rm -rf`?");
178        assert_eq!(sink.category, "needs_approval");
179        assert_eq!(sink.priority, "high");
180        assert_eq!(sink.session_id, "sess-1");
181        assert_eq!(sink.id.as_deref(), Some("n1"));
182        assert_eq!(sink.dedup_key.as_deref(), Some("dk"));
183        assert_eq!(sink.created_at.as_deref(), Some("2026-01-01T00:00:00Z"));
184        assert_eq!(sink.click_url, None);
185    }
186
187    #[test]
188    fn from_event_rejects_non_notification_variants() {
189        let event = bamboo_agent_core::AgentEvent::Token {
190            content: "hi".to_string(),
191        };
192        assert!(SinkNotification::from_event(&event).is_none());
193    }
194
195    #[test]
196    fn dispatch_to_sinks_does_not_panic_with_everything_disabled() {
197        // No enabled channels (desktop explicitly off, ntfy/bark default off)
198        // — must be a pure no-op, not a panic or a hang.
199        let mut config = Config::default();
200        config.notifications.desktop.enabled = Some(false);
201        let notification = SinkNotification::from_event(&sample()).unwrap();
202        dispatch_to_sinks(&config, false, &notification);
203    }
204
205    #[test]
206    fn attempted_channels_reports_none_when_everything_disabled() {
207        let mut config = Config::default();
208        config.notifications.desktop.enabled = Some(false);
209        assert!(attempted_channels(&config, false, "custom").is_empty());
210    }
211
212    #[test]
213    fn attempted_channels_reports_enabled_notification_command_hook() {
214        let mut config = Config::default();
215        config.notifications.desktop.enabled = Some(false);
216        config.lifecycle_hooks = bamboo_config::LifecycleHooksConfig {
217            enabled: true,
218            notification: vec![bamboo_config::LifecycleHookGroup {
219                enabled: true,
220                matcher: None,
221                hooks: vec![bamboo_config::LifecycleHookHandler::command(
222                    "true",
223                    bamboo_config::DEFAULT_LIFECYCLE_HOOK_TIMEOUT_MS,
224                )],
225            }],
226            ..Default::default()
227        };
228
229        assert_eq!(
230            attempted_channels(&config, false, "custom"),
231            vec!["command"]
232        );
233    }
234
235    #[test]
236    fn attempted_channels_reports_every_enabled_channel_in_order() {
237        let mut config = Config::default();
238        config.notifications.desktop.enabled = Some(true);
239        config.notifications.ntfy.enabled = true;
240        config.notifications.bark.enabled = true;
241        assert_eq!(
242            attempted_channels(&config, false, "custom"),
243            vec!["desktop", "ntfy", "bark"]
244        );
245    }
246
247    #[test]
248    fn attempted_channels_omits_desktop_when_suppressed_by_watcher() {
249        let mut config = Config::default();
250        config.notifications.desktop.enabled = Some(true);
251        config.notifications.ntfy.enabled = true;
252        // run_completed + a live watcher suppresses ONLY the desktop popup.
253        assert_eq!(
254            attempted_channels(&config, true, "run_completed"),
255            vec!["ntfy"]
256        );
257    }
258
259    #[test]
260    fn attempted_channels_matches_what_dispatch_to_sinks_would_attempt() {
261        // A lightweight cross-check that the two never drift: every channel
262        // `attempted_channels` reports is one `dispatch_to_sinks` actually has
263        // gating logic for (desktop/ntfy/bark), for a representative matrix.
264        let matrices = [
265            (Some(true), true, true),
266            (Some(false), false, true),
267            (None, true, false),
268        ];
269        for (desktop_enabled, ntfy_enabled, bark_enabled) in matrices {
270            let mut config = Config::default();
271            config.notifications.desktop.enabled = desktop_enabled;
272            config.notifications.ntfy.enabled = ntfy_enabled;
273            config.notifications.bark.enabled = bark_enabled;
274            let names = attempted_channels(&config, false, "custom");
275            assert!(names
276                .iter()
277                .all(|c| ["command", "desktop", "ntfy", "bark"].contains(c)));
278        }
279    }
280}