//! ntfy delivery for the three DESIGN.md §9.5 events: imported (to a title's //! owners), needs-a-decision and broken (both to the operator alone). See //! `attention.rs` and `broken.rs` for what decides *when* to send. use std::time::Duration; use reqwest::Client; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, thiserror::Error)] pub enum NotifyError { #[error("request: {0}")] Request(#[from] reqwest::Error), #[error("ntfy returned {0}")] Status(reqwest::StatusCode), } /// A client for posting to one ntfy server, many topics. #[derive(Debug, Clone)] pub struct Notifier { client: Client, base_url: String, } impl Notifier { pub fn new(base_url: impl Into) -> Result { let client = Client::builder().timeout(REQUEST_TIMEOUT).build()?; Ok(Self { client, base_url: base_url.into(), }) } /// Post a plain-text message to one topic. The title is the message's /// first line rather than ntfy's `Title` header — that header must be /// ASCII, and titles here are TMDB titles in whatever language they were /// released. pub async fn send(&self, topic: &str, title: &str, body: &str) -> Result<(), NotifyError> { let url = format!("{}/{topic}", self.base_url.trim_end_matches('/')); let response = self .client .post(url) .body(format!("{title}\n{body}")) .send() .await?; if !response.status().is_success() { return Err(NotifyError::Status(response.status())); } Ok(()) } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use wiremock::matchers::{body_string, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; #[tokio::test] async fn send_posts_title_and_body_to_the_topic() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/owner-topic")) .and(body_string("Dune: Part Two (2024)\nimported")) .respond_with(ResponseTemplate::new(200)) .mount(&server) .await; let notifier = Notifier::new(server.uri()).unwrap(); notifier .send("owner-topic", "Dune: Part Two (2024)", "imported") .await .unwrap(); } #[tokio::test] async fn a_non_success_status_is_an_error() { let server = MockServer::start().await; Mock::given(method("POST")) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; let notifier = Notifier::new(server.uri()).unwrap(); assert!(matches!( notifier.send("topic", "title", "body").await, Err(NotifyError::Status(_)) )); } #[tokio::test] async fn an_unreachable_server_is_an_error_the_caller_can_swallow() { let notifier = Notifier::new("http://127.0.0.1:1").unwrap(); assert!(matches!( notifier.send("topic", "title", "body").await, Err(NotifyError::Request(_)) )); } }