use std::sync::OnceLock;
use bamboo_config::NtfyChannelConfig;
use super::{NotificationSink, SinkNotification};
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}
fn ntfy_priority(priority: &str) -> &'static str {
if priority == "high" {
"high"
} else {
"default"
}
}
pub(super) struct NtfySink {
base_url: String,
topic: String,
token: Option<String>,
}
impl NtfySink {
pub(super) fn from_config(config: &NtfyChannelConfig) -> Self {
Self {
base_url: config.base_url.clone(),
topic: config.topic.clone(),
token: config.token.clone(),
}
}
}
impl NotificationSink for NtfySink {
fn name(&self) -> &'static str {
"ntfy"
}
fn deliver(&self, n: &SinkNotification) {
if self.topic.trim().is_empty() {
tracing::warn!("ntfy sink: enabled with an empty topic; skipping delivery");
return;
}
let url = format!("{}/{}", self.base_url.trim_end_matches('/'), self.topic);
let mut request = http_client()
.post(url)
.body(n.body.clone())
.header("Title", n.title.clone())
.header("Priority", ntfy_priority(&n.priority))
.header("Tags", n.category.clone());
if let Some(token) = self.token.as_deref().filter(|t| !t.is_empty()) {
request = request.bearer_auth(token);
}
if let Some(click_url) = &n.click_url {
request = request.header("Click", click_url.clone());
}
tokio::spawn(async move {
if let Err(error) = request.send().await {
tracing::warn!(%error, "ntfy sink: delivery failed");
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ntfy_priority_maps_high_and_defaults_everything_else() {
assert_eq!(ntfy_priority("high"), "high");
assert_eq!(ntfy_priority("normal"), "default");
assert_eq!(ntfy_priority("low"), "default");
}
#[test]
fn from_config_copies_fields() {
let config = NtfyChannelConfig {
enabled: true,
base_url: "https://ntfy.example.com".to_string(),
topic: "bamboo-alerts".to_string(),
token: Some("tk".to_string()),
token_encrypted: None,
};
let sink = NtfySink::from_config(&config);
assert_eq!(sink.base_url, "https://ntfy.example.com");
assert_eq!(sink.topic, "bamboo-alerts");
assert_eq!(sink.token.as_deref(), Some("tk"));
}
fn sample_notification() -> SinkNotification {
SinkNotification {
title: "Approval needed".to_string(),
body: "please approve".to_string(),
category: "needs_approval".to_string(),
priority: "high".to_string(),
session_id: "sess-1".to_string(),
click_url: None,
}
}
async fn wait_for_requests(
server: &wiremock::MockServer,
expected: usize,
) -> Vec<wiremock::Request> {
for _ in 0..50 {
if let Some(requests) = server.received_requests().await {
if requests.len() >= expected {
return requests;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
server.received_requests().await.unwrap_or_default()
}
#[tokio::test]
async fn deliver_posts_expected_method_path_headers_and_body() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
let sink = NtfySink {
base_url: server.uri(),
topic: "bamboo-alerts".to_string(),
token: Some("secret-token".to_string()),
};
sink.deliver(&sample_notification());
let requests = wait_for_requests(&server, 1).await;
assert_eq!(requests.len(), 1, "expected exactly one delivered request");
let request = &requests[0];
assert_eq!(request.method.as_str(), "POST");
assert_eq!(request.url.path(), "/bamboo-alerts");
assert_eq!(
request.headers.get("title").and_then(|v| v.to_str().ok()),
Some("Approval needed")
);
assert_eq!(
request
.headers
.get("priority")
.and_then(|v| v.to_str().ok()),
Some("high")
);
assert_eq!(
request.headers.get("tags").and_then(|v| v.to_str().ok()),
Some("needs_approval")
);
assert_eq!(
request
.headers
.get("authorization")
.and_then(|v| v.to_str().ok()),
Some("Bearer secret-token")
);
assert_eq!(
std::str::from_utf8(&request.body).unwrap(),
"please approve"
);
}
#[tokio::test]
async fn deliver_skips_empty_topic_without_sending() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
let sink = NtfySink {
base_url: server.uri(),
topic: String::new(),
token: None,
};
sink.deliver(&sample_notification());
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(server.received_requests().await.map(|r| r.len()), Some(0));
}
}