mod bark;
mod desktop;
mod ntfy;
pub use desktop::{is_sidecar_mode, set_sidecar_mode};
use bamboo_llm::Config;
#[derive(Debug, Clone, PartialEq)]
pub struct SinkNotification {
pub title: String,
pub body: String,
pub category: String,
pub priority: String,
pub session_id: String,
pub click_url: Option<String>,
}
impl SinkNotification {
pub fn from_event(event: &bamboo_agent_core::AgentEvent) -> Option<Self> {
match event {
bamboo_agent_core::AgentEvent::Notification {
session_id,
category,
priority,
title,
body,
..
} => Some(Self {
title: title.clone(),
body: body.clone(),
category: category.clone(),
priority: priority.clone(),
session_id: session_id.clone(),
click_url: None,
}),
_ => None,
}
}
}
pub trait NotificationSink: Send + Sync {
#[allow(dead_code)] fn name(&self) -> &'static str;
fn deliver(&self, n: &SinkNotification);
}
pub fn attempted_channels(config: &Config, has_watcher: bool, category: &str) -> Vec<&'static str> {
let mut channels = Vec::new();
if desktop::desktop_enabled(
config.notifications.desktop.enabled,
desktop::is_sidecar_mode(),
) && !desktop::suppressed_by_watcher(category, has_watcher)
{
channels.push("desktop");
}
if config.notifications.ntfy.enabled {
channels.push("ntfy");
}
if config.notifications.bark.enabled {
channels.push("bark");
}
channels
}
pub fn dispatch_to_sinks(config: &Config, has_watcher: bool, notification: &SinkNotification) {
for channel in attempted_channels(config, has_watcher, ¬ification.category) {
match channel {
"desktop" => desktop::DesktopSink.deliver(notification),
"ntfy" => ntfy::NtfySink::from_config(&config.notifications.ntfy).deliver(notification),
"bark" => bark::BarkSink::from_config(&config.notifications.bark).deliver(notification),
_ => unreachable!("attempted_channels only ever returns known channel names"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> bamboo_agent_core::AgentEvent {
bamboo_agent_core::AgentEvent::Notification {
id: "n1".to_string(),
session_id: "sess-1".to_string(),
category: "needs_approval".to_string(),
priority: "high".to_string(),
title: "Approval needed".to_string(),
body: "run `rm -rf`?".to_string(),
dedup_key: Some("dk".to_string()),
created_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[test]
fn from_event_extracts_sink_fields() {
let sink = SinkNotification::from_event(&sample()).unwrap();
assert_eq!(sink.title, "Approval needed");
assert_eq!(sink.body, "run `rm -rf`?");
assert_eq!(sink.category, "needs_approval");
assert_eq!(sink.priority, "high");
assert_eq!(sink.session_id, "sess-1");
assert_eq!(sink.click_url, None);
}
#[test]
fn from_event_rejects_non_notification_variants() {
let event = bamboo_agent_core::AgentEvent::Token {
content: "hi".to_string(),
};
assert!(SinkNotification::from_event(&event).is_none());
}
#[test]
fn dispatch_to_sinks_does_not_panic_with_everything_disabled() {
let mut config = Config::default();
config.notifications.desktop.enabled = Some(false);
let notification = SinkNotification::from_event(&sample()).unwrap();
dispatch_to_sinks(&config, false, ¬ification);
}
#[test]
fn attempted_channels_reports_none_when_everything_disabled() {
let mut config = Config::default();
config.notifications.desktop.enabled = Some(false);
assert!(attempted_channels(&config, false, "custom").is_empty());
}
#[test]
fn attempted_channels_reports_every_enabled_channel_in_order() {
let mut config = Config::default();
config.notifications.desktop.enabled = Some(true);
config.notifications.ntfy.enabled = true;
config.notifications.bark.enabled = true;
assert_eq!(
attempted_channels(&config, false, "custom"),
vec!["desktop", "ntfy", "bark"]
);
}
#[test]
fn attempted_channels_omits_desktop_when_suppressed_by_watcher() {
let mut config = Config::default();
config.notifications.desktop.enabled = Some(true);
config.notifications.ntfy.enabled = true;
assert_eq!(
attempted_channels(&config, true, "run_completed"),
vec!["ntfy"]
);
}
#[test]
fn attempted_channels_matches_what_dispatch_to_sinks_would_attempt() {
let matrices = [
(Some(true), true, true),
(Some(false), false, true),
(None, true, false),
];
for (desktop_enabled, ntfy_enabled, bark_enabled) in matrices {
let mut config = Config::default();
config.notifications.desktop.enabled = desktop_enabled;
config.notifications.ntfy.enabled = ntfy_enabled;
config.notifications.bark.enabled = bark_enabled;
let names = attempted_channels(&config, false, "custom");
assert!(names
.iter()
.all(|c| ["desktop", "ntfy", "bark"].contains(c)));
}
}
}