Files
arr/crates/arr-daemon/src/notify.rs
T
naps62-yolo ad0eb0d9d3
ci / web (push) Successful in 1m0s
e2e / e2e (push) Successful in 3m41s
ci / rust (push) Successful in 5m42s
feat: ntfy notifications for imported, needs-decision, broken (#96)
2026-08-23 02:04:36 +01:00

106 lines
3.1 KiB
Rust

//! 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<String>) -> Result<Self, NotifyError> {
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(_))
));
}
}