//! ntfy.sh channel adapter. //! //! Subscribes to a ntfy topic via Server-Sent Events (SSE) for receiving //! messages and publishes replies by POSTing to the same topic endpoint. //! Supports self-hosted ntfy instances and optional Bearer token auth. use crate::types::{ split_message, ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser, }; use async_trait::async_trait; use chrono::Utc; use futures::Stream; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, watch}; use tracing::{info, warn}; use zeroize::Zeroizing; const MAX_MESSAGE_LEN: usize = 4096; const DEFAULT_SERVER_URL: &str = "https://ntfy.sh"; /// ntfy.sh pub/sub channel adapter. /// /// Subscribes to notifications via SSE and publishes replies as new /// notifications. Supports authentication for protected topics. pub struct NtfyAdapter { /// ntfy server URL (default: `"https://ntfy.sh"`). server_url: String, /// Topic name to subscribe and publish to. topic: String, /// SECURITY: Bearer token is zeroized on drop (empty = no auth). token: Zeroizing, /// HTTP client. client: reqwest::Client, /// Shutdown signal. shutdown_tx: Arc>, shutdown_rx: watch::Receiver, } impl NtfyAdapter { /// Create a new ntfy adapter. /// /// # Arguments /// * `server_url` - ntfy server URL (empty = default `"https://ntfy.sh"`). /// * `topic` - Topic name to subscribe/publish to. /// * `token` - Bearer token for authentication (empty = no auth). pub fn new(server_url: String, topic: String, token: String) -> Self { let (shutdown_tx, shutdown_rx) = watch::channel(false); let server_url = if server_url.is_empty() { DEFAULT_SERVER_URL.to_string() } else { server_url.trim_end_matches('/').to_string() }; Self { server_url, topic, token: Zeroizing::new(token), client: reqwest::Client::new(), shutdown_tx: Arc::new(shutdown_tx), shutdown_rx, } } /// Build an authenticated request builder. fn auth_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if self.token.is_empty() { builder } else { builder.bearer_auth(self.token.as_str()) } } /// Parse an SSE data line into a ntfy message. /// /// ntfy SSE format: /// ```text /// event: message /// data: {"id":"abc","time":1234,"event":"message","topic":"test","message":"Hello"} /// ``` fn parse_sse_data(data: &str) -> Option<(String, String, String, Option)> { let val: serde_json::Value = serde_json::from_str(data).ok()?; // Only process "message" events (skip "open", "keepalive", etc.) let event = val["event"].as_str().unwrap_or(""); if event != "message" { return None; } let id = val["id"].as_str()?.to_string(); let message = val["message"].as_str()?.to_string(); let topic = val["topic"].as_str().unwrap_or("").to_string(); if message.is_empty() { return None; } // ntfy messages can have a title (used as sender hint) let title = val["title"].as_str().map(String::from); Some((id, message, topic, title)) } /// Publish a message to the topic. async fn publish( &self, text: &str, title: Option<&str>, ) -> Result<(), Box> { let url = format!("{}/{}", self.server_url, self.topic); let chunks = split_message(text, MAX_MESSAGE_LEN); for chunk in chunks { let mut builder = self.client.post(&url); builder = self.auth_request(builder); // ntfy supports plain-text body publishing builder = builder.header("Content-Type", "text/plain"); if let Some(t) = title { builder = builder.header("Title", t); } // Mark as UTF-8 builder = builder.header("X-Message", chunk); let resp = builder.body(chunk.to_string()).send().await?; if !resp.status().is_success() { let status = resp.status(); let err_body = resp.text().await.unwrap_or_default(); return Err(format!("ntfy publish error {status}: {err_body}").into()); } } Ok(()) } } #[async_trait] impl ChannelAdapter for NtfyAdapter { fn name(&self) -> &str { "ntfy" } fn channel_type(&self) -> ChannelType { ChannelType::Custom("ntfy".to_string()) } async fn start( &self, ) -> Result + Send>>, Box> { info!( "ntfy adapter subscribing to {}/{}", self.server_url, self.topic ); let (tx, rx) = mpsc::channel::(256); let server_url = self.server_url.clone(); let topic = self.topic.clone(); let token = self.token.clone(); let mut shutdown_rx = self.shutdown_rx.clone(); tokio::spawn(async move { let sse_client = reqwest::Client::builder() .timeout(Duration::from_secs(0)) // No timeout for SSE .build() .unwrap_or_default(); let mut backoff = Duration::from_secs(1); loop { if *shutdown_rx.borrow() { break; } let url = format!("{}/{}/sse", server_url, topic); let mut builder = sse_client.get(&url); if !token.is_empty() { builder = builder.bearer_auth(token.as_str()); } let response = match builder.send().await { Ok(r) => { if !r.status().is_success() { warn!("ntfy: SSE returned HTTP {}", r.status()); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(120)); continue; } backoff = Duration::from_secs(1); r } Err(e) => { warn!("ntfy: SSE connection error: {e}, backing off {backoff:?}"); tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(120)); continue; } }; info!("ntfy: SSE stream connected for topic {topic}"); let mut stream = response.bytes_stream(); use futures::StreamExt; let mut line_buffer = String::new(); let mut current_data = String::new(); loop { tokio::select! { _ = shutdown_rx.changed() => { if *shutdown_rx.borrow() { info!("ntfy adapter shutting down"); return; } } chunk = stream.next() => { match chunk { Some(Ok(bytes)) => { let text = String::from_utf8_lossy(&bytes); line_buffer.push_str(&text); // SSE parsing: process complete lines while let Some(newline_pos) = line_buffer.find('\n') { let line = line_buffer[..newline_pos].trim_end_matches('\r').to_string(); line_buffer = line_buffer[newline_pos + 1..].to_string(); if let Some(data) = line.strip_prefix("data: ") { current_data = data.to_string(); } else if line.is_empty() && !current_data.is_empty() { // Empty line = end of SSE event if let Some((id, message, _topic, title)) = Self::parse_sse_data(¤t_data) { let sender_name = title .as_deref() .unwrap_or("ntfy-user"); let content = if message.starts_with('/') { let parts: Vec<&str> = message.splitn(2, ' ').collect(); let cmd = parts[0].trim_start_matches('/'); let args: Vec = parts .get(1) .map(|a| { a.split_whitespace() .map(String::from) .collect() }) .unwrap_or_default(); ChannelContent::Command { name: cmd.to_string(), args, } } else { ChannelContent::Text(message) }; let msg = ChannelMessage { channel: ChannelType::Custom( "ntfy".to_string(), ), platform_message_id: id, sender: ChannelUser { platform_id: sender_name.to_string(), display_name: sender_name.to_string(), openfang_user: None, }, content, target_agent: None, timestamp: Utc::now(), is_group: true, thread_id: None, metadata: { let mut m = HashMap::new(); m.insert( "topic".to_string(), serde_json::Value::String( topic.clone(), ), ); m }, }; if tx.send(msg).await.is_err() { return; } } current_data.clear(); } } } Some(Err(e)) => { warn!("ntfy: SSE read error: {e}"); break; } None => { info!("ntfy: SSE stream ended, reconnecting..."); break; } } } } } // Backoff before reconnect if !*shutdown_rx.borrow() { tokio::time::sleep(backoff).await; backoff = (backoff * 2).min(Duration::from_secs(60)); } } info!("ntfy SSE loop stopped"); }); Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))) } async fn send( &self, _user: &ChannelUser, content: ChannelContent, ) -> Result<(), Box> { let text = match content { ChannelContent::Text(t) => t, _ => "(Unsupported content type)".to_string(), }; self.publish(&text, Some("OpenFang")).await } async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box> { // ntfy has no typing indicator concept. Ok(()) } async fn stop(&self) -> Result<(), Box> { let _ = self.shutdown_tx.send(true); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_ntfy_adapter_creation() { let adapter = NtfyAdapter::new("".to_string(), "my-topic".to_string(), "".to_string()); assert_eq!(adapter.name(), "ntfy"); assert_eq!( adapter.channel_type(), ChannelType::Custom("ntfy".to_string()) ); assert_eq!(adapter.server_url, DEFAULT_SERVER_URL); } #[test] fn test_ntfy_custom_server_url() { let adapter = NtfyAdapter::new( "https://ntfy.internal.corp/".to_string(), "alerts".to_string(), "token-123".to_string(), ); assert_eq!(adapter.server_url, "https://ntfy.internal.corp"); assert_eq!(adapter.topic, "alerts"); } #[test] fn test_ntfy_auth_request_with_token() { let adapter = NtfyAdapter::new( "".to_string(), "test".to_string(), "my-bearer-token".to_string(), ); let builder = adapter.client.get("https://ntfy.sh/test"); let builder = adapter.auth_request(builder); let request = builder.build().unwrap(); assert!(request.headers().contains_key("authorization")); } #[test] fn test_ntfy_auth_request_without_token() { let adapter = NtfyAdapter::new("".to_string(), "test".to_string(), "".to_string()); let builder = adapter.client.get("https://ntfy.sh/test"); let builder = adapter.auth_request(builder); let request = builder.build().unwrap(); assert!(!request.headers().contains_key("authorization")); } #[test] fn test_ntfy_parse_sse_message_event() { let data = r#"{"id":"abc123","time":1700000000,"event":"message","topic":"test","message":"Hello from ntfy","title":"Alice"}"#; let result = NtfyAdapter::parse_sse_data(data); assert!(result.is_some()); let (id, message, topic, title) = result.unwrap(); assert_eq!(id, "abc123"); assert_eq!(message, "Hello from ntfy"); assert_eq!(topic, "test"); assert_eq!(title.as_deref(), Some("Alice")); } #[test] fn test_ntfy_parse_sse_keepalive_event() { let data = r#"{"id":"ka1","time":1700000000,"event":"keepalive","topic":"test"}"#; assert!(NtfyAdapter::parse_sse_data(data).is_none()); } #[test] fn test_ntfy_parse_sse_open_event() { let data = r#"{"id":"o1","time":1700000000,"event":"open","topic":"test"}"#; assert!(NtfyAdapter::parse_sse_data(data).is_none()); } #[test] fn test_ntfy_parse_sse_empty_message() { let data = r#"{"id":"e1","time":1700000000,"event":"message","topic":"test","message":""}"#; assert!(NtfyAdapter::parse_sse_data(data).is_none()); } #[test] fn test_ntfy_parse_sse_no_title() { let data = r#"{"id":"nt1","time":1700000000,"event":"message","topic":"test","message":"Hi"}"#; let result = NtfyAdapter::parse_sse_data(data); assert!(result.is_some()); let (_, _, _, title) = result.unwrap(); assert!(title.is_none()); } #[test] fn test_ntfy_parse_invalid_json() { assert!(NtfyAdapter::parse_sse_data("not json").is_none()); } }