bamboo_server/notify_sinks/
mod.rs1mod bark;
18mod command;
19mod desktop;
20mod ntfy;
21
22pub use desktop::{is_sidecar_mode, set_sidecar_mode};
23
24use bamboo_llm::Config;
25
26#[derive(Debug, Clone, PartialEq)]
32pub struct SinkNotification {
33 pub id: Option<String>,
34 pub title: String,
35 pub body: String,
36 pub category: String,
39 pub priority: String,
42 pub session_id: String,
43 pub dedup_key: Option<String>,
44 pub created_at: Option<String>,
45 pub click_url: Option<String>,
48}
49
50impl SinkNotification {
51 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
82pub trait NotificationSink: Send + Sync {
89 #[allow(dead_code)] fn name(&self) -> &'static str;
92
93 fn deliver(&self, n: &SinkNotification);
96}
97
98pub 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
133pub fn dispatch_to_sinks(config: &Config, has_watcher: bool, notification: &SinkNotification) {
145 for channel in attempted_channels(config, has_watcher, ¬ification.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 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, ¬ification);
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 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 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}