Skip to main content

chipa_webhooks/platform/
ntfy.rs

1use serde_json::{Map, Value, json};
2
3use super::Platform;
4
5pub struct Ntfy {
6    url: String,
7    topic: String,
8    title: Option<String>,
9    priority: Option<u8>,
10    tags: Vec<String>,
11}
12
13impl Ntfy {
14    /// `url` is the ntfy server base URL, e.g. `"https://ntfy.sh"` or a self-hosted instance.
15    /// `topic` is the topic to publish to, e.g. `"trading-alerts"`.
16    pub fn new(url: impl Into<String>, topic: impl Into<String>) -> Self {
17        Self {
18            url: url.into(),
19            topic: topic.into(),
20            title: None,
21            priority: None,
22            tags: Vec::new(),
23        }
24    }
25
26    pub fn with_title(mut self, title: impl Into<String>) -> Self {
27        self.title = Some(title.into());
28        self
29    }
30
31    /// Priority: 1 (min) to 5 (max). Default is 3 (default).
32    pub fn with_priority(mut self, priority: u8) -> Self {
33        self.priority = Some(priority.clamp(1, 5));
34        self
35    }
36
37    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
38        self.tags = tags.into_iter().map(Into::into).collect();
39        self
40    }
41}
42
43impl Platform for Ntfy {
44    fn build_payload(&self, rendered: &str, hints: &Map<String, Value>) -> Value {
45        let title = hints
46            .get("__ntfy_title")
47            .and_then(Value::as_str)
48            .map(str::to_owned)
49            .or_else(|| self.title.clone());
50
51        let priority = hints
52            .get("__ntfy_priority")
53            .and_then(Value::as_u64)
54            .map(|p| (p as u8).clamp(1, 5))
55            .or(self.priority);
56
57        // Hints can supply extra tags as a comma-separated string on top of the
58        // default tags registered on the struct.
59        let mut tags = self.tags.clone();
60        if let Some(extra) = hints.get("__ntfy_tags").and_then(Value::as_str) {
61            tags.extend(extra.split(',').map(|t| t.trim().to_owned()));
62        }
63
64        let mut payload = json!({
65            "topic":   self.topic,
66            "message": rendered,
67        });
68
69        if let Some(t) = title {
70            payload["title"] = Value::String(t);
71        }
72
73        if let Some(p) = priority {
74            payload["priority"] = Value::Number(p.into());
75        }
76
77        if !tags.is_empty() {
78            payload["tags"] = Value::Array(tags.into_iter().map(Value::String).collect());
79        }
80
81        payload
82    }
83
84    fn endpoint(&self) -> &str {
85        // ntfy REST endpoint is just the base URL — the topic is in the payload body.
86        // We leak once at startup since Ntfy instances live for the program lifetime.
87        Box::leak(format!("{}/", self.url.trim_end_matches('/')).into_boxed_str())
88    }
89}