autopulse_service/settings/webhooks/
hookshot.rs1use super::{transport, EventType, WebhookBatch};
2use autopulse_utils::sify;
3use html_escape::encode_text;
4use serde::{Deserialize, Serialize};
5
6#[derive(Serialize, Clone)]
7struct HookshotPayload {
8 text: String,
9 html: String,
10 username: String,
11}
12
13#[derive(Serialize, Deserialize, Clone)]
14pub struct HookshotWebhook {
15 pub url: String,
17 pub username: Option<String>,
19}
20
21impl HookshotWebhook {
22 fn summary_line(event: &EventType, trigger: Option<&str>, files: &[String]) -> String {
23 trigger.map_or_else(
24 || {
25 format!(
26 "[{event}] - {} file{} {}",
27 files.len(),
28 sify(files),
29 event.action()
30 )
31 },
32 |trigger| {
33 format!(
34 "[{event}] - [{trigger}] - {} file{} {}",
35 files.len(),
36 sify(files),
37 event.action()
38 )
39 },
40 )
41 }
42
43 fn generate_payload(&self, batch: &WebhookBatch) -> HookshotPayload {
44 let username = self
45 .username
46 .clone()
47 .unwrap_or_else(|| "autopulse".to_string());
48 let sections = batch
49 .iter()
50 .map(|(event, trigger, files)| {
51 let summary = Self::summary_line(event, trigger.as_deref(), files);
52 let files = files.join("\n");
53
54 format!("{summary}\n{files}")
55 })
56 .collect::<Vec<_>>();
57 let text = sections.join("\n\n");
58 let html = batch
59 .iter()
60 .map(|(event, trigger, files)| {
61 let raw_summary = Self::summary_line(event, trigger.as_deref(), files);
62 let summary = encode_text(&raw_summary);
63 let files = files
64 .iter()
65 .map(|file| format!("<li><code>{}</code></li>", encode_text(file)))
66 .collect::<Vec<_>>()
67 .join("");
68
69 format!("<li><strong>{summary}</strong><ul>{files}</ul></li>")
70 })
71 .collect::<Vec<_>>()
72 .join("");
73 let html = format!("<ul>{html}</ul>");
74
75 HookshotPayload {
76 text,
77 html,
78 username,
79 }
80 }
81
82 pub async fn send(
83 &self,
84 batch: &WebhookBatch,
85 retries: u8,
86 timeout_secs: u64,
87 ) -> anyhow::Result<()> {
88 let payload = self.generate_payload(batch);
89
90 transport::shared_sender(std::time::Duration::from_secs(timeout_secs))?
91 .send_json(&self.url, &[payload], retries)
92 .await
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 fn sample_batch() -> WebhookBatch {
101 vec![
102 (
103 EventType::Processed,
104 Some("sonarr".to_string()),
105 vec!["/media/tv/show-01.mkv".to_string()],
106 ),
107 (
108 EventType::Failed,
109 None,
110 vec!["/media/movies/movie-01.mkv".to_string()],
111 ),
112 ]
113 }
114
115 #[test]
116 fn generate_payload_defaults_username_and_summarizes_each_batch_item() {
117 let webhook = HookshotWebhook {
118 url: "https://example.com/webhooks/hookshot".to_string(),
119 username: None,
120 };
121
122 let payload = webhook.generate_payload(&sample_batch());
123
124 assert_eq!(payload.username, "autopulse");
125 assert!(payload
126 .text
127 .contains("[PROCESSED] - [sonarr] - 1 file processed"));
128 assert!(payload.text.contains("/media/tv/show-01.mkv"));
129 assert!(payload.text.contains("[FAILED] - 1 file failed"));
130 assert!(payload
131 .html
132 .contains("<code>/media/movies/movie-01.mkv</code>"));
133 }
134
135 #[test]
136 fn html_entities_in_filenames_are_escaped() {
137 let batch: WebhookBatch = vec![(
138 EventType::Processed,
139 Some("sonarr".to_string()),
140 vec![
141 r#"/media/tv/<script>alert("xss")</script>.mkv"#.to_string(),
142 "/media/tv/Tom & Jerry's Show.mkv".to_string(),
143 ],
144 )];
145
146 let webhook = HookshotWebhook {
147 url: "https://example.com/hook".to_string(),
148 username: None,
149 };
150
151 let payload = webhook.generate_payload(&batch);
152
153 assert!(payload.html.contains("<script>"));
155 assert!(payload.html.contains("</script>"));
156 assert!(payload.html.contains("Tom & Jerry"));
158 assert!(payload.html.contains(r#"alert("xss")"#));
160 assert!(payload.html.contains("Jerry's"));
161 assert!(!payload.html.contains("<script>"));
163 }
164}