infraqueue_webhookloader/
lib.rs1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
7use thiserror::Error;
8use toml::Value;
9
10#[derive(Debug, Error)]
11pub enum WebHookError {
12 #[error("config error: {0}")]
13 ConfigError(String),
14 #[error("execution error: {0}")]
15 ExecutionError(String),
16}
17
18#[derive(Debug, Clone)]
19struct HookEntry {
20 url: String,
21 body: Option<String>,
22}
23
24#[derive(Debug, Clone)]
25pub struct WebHookLoader {
26 config_path: PathBuf,
27 hooks: HashMap<String, HookEntry>,
28}
29
30impl WebHookLoader {
31 pub fn new(config_path: &str) -> Result<Self, WebHookError> {
32 let path = PathBuf::from(config_path);
33 let content = fs::read_to_string(&path)
34 .map_err(|e| WebHookError::ConfigError(format!("failed to read config {config_path}: {e}")))?;
35 let hooks = parse_config(&content)?;
36 Ok(Self { config_path: path, hooks })
37 }
38
39 pub fn list_hooks(&self) -> Result<Vec<String>, WebHookError> {
40 let mut names: Vec<String> = self.hooks.keys().cloned().collect();
41 names.sort();
42 Ok(names)
43 }
44
45 pub async fn fire_hook_result(&self, name: &str) -> Result<(), WebHookError> {
46 let entry = self
47 .hooks
48 .get(name)
49 .ok_or_else(|| WebHookError::ConfigError(format!("unknown webhook name: {name}")))?;
50 if entry.url.trim().is_empty() {
51 return Err(WebHookError::ConfigError("empty URL in config".into()));
52 }
53
54 let client = reqwest::Client::new();
55 let url = &entry.url;
56 let body_opt = entry.body.as_ref();
57
58 let mut last_err: Option<String> = None;
59 for attempt in 1..=10 {
60 let mut req = client.post(url);
61 let mut headers = HeaderMap::new();
62
63 if let Some(body) = body_opt {
64 let trimmed = body.trim_start();
66 let ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
67 "application/json"
68 } else {
69 "text/plain; charset=utf-8"
70 };
71 headers.insert(
72 CONTENT_TYPE,
73 HeaderValue::from_str(ct).unwrap_or(HeaderValue::from_static("text/plain; charset=utf-8")),
74 );
75 req = req.headers(headers).body(body.clone());
76 } else {
77 req = req.headers(headers);
78 }
79
80 match req.send().await {
81 Ok(resp) => {
82 if resp.status().is_success() {
83 return Ok(());
84 } else {
85 let status = resp.status();
86 last_err = Some(format!("non-success status {status}"));
87 }
88 }
89 Err(e) => {
90 last_err = Some(format!("request error: {e}"));
91 }
92 }
93
94 if attempt < 10 {
95 tokio::time::sleep(Duration::from_millis(200 * attempt)).await;
96 }
97 }
98
99 Err(WebHookError::ExecutionError(
100 last_err.unwrap_or_else(|| "failed to send request".to_string()),
101 ))
102 }
103}
104
105fn parse_config(content: &str) -> Result<HashMap<String, HookEntry>, WebHookError> {
106 let value: Value = toml::from_str(content)
107 .map_err(|e| WebHookError::ConfigError(format!("invalid TOML: {e}")))?;
108 let table = value
109 .as_table()
110 .ok_or_else(|| WebHookError::ConfigError("top-level TOML must be a table".into()))?;
111
112 let mut map: HashMap<String, HookEntry> = HashMap::new();
113 for (name, val) in table.iter() {
114 let s = val
115 .as_str()
116 .ok_or_else(|| WebHookError::ConfigError(format!("value for '{name}' must be a string")))?;
117 let (url, body) = split_url_body(s);
118 if url.trim().is_empty() {
119 return Err(WebHookError::ConfigError(format!("empty URL for '{name}'")));
120 }
121 map.insert(
122 name.clone(),
123 HookEntry {
124 url: url.to_string(),
125 body: body.map(|b| b.to_string()),
126 },
127 );
128 }
129 Ok(map)
130}
131
132fn split_url_body(s: &str) -> (&str, Option<&str>) {
133 if let Some(idx) = s.find(":::") {
134 let (u, rest) = s.split_at(idx);
135 let body = &rest[3..];
137 (u, Some(body))
138 } else {
139 (s, None)
140 }
141}
142
143fn default_config_path() -> String {
144 let mut default_path = "/infraqueue/config.toml".to_string();
145
146 let mut loop_count = 0;
147 while !Path::new(&default_path).exists() {
148 match loop_count {
149 0 => {
150 if let Ok(p) = std::env::var("WEBHOOKLOADER_CONFIG") {
151 if !p.trim().is_empty() {
152 default_path = p;
153 }
154 }
155 }
156 1 => {
157 default_path = "/etc/infraqueue/config.toml".to_string();
158 }
159 _ => {
160 default_path = "./config.toml".to_string();
161 break;
162 }
163 }
164 loop_count += 1;
165 }
166
167 println!("using config path: {}", default_path);
168 default_path
169}
170
171pub async fn send_webhook_by_name(name: &str) -> Result<(), WebHookError> {
172 let path = default_config_path();
173 send_webhook_by_name_with_config(&path, name).await
174}
175
176pub async fn send_webhook_by_name_with_config(
177 config_path: &str,
178 name: &str,
179) -> Result<(), WebHookError> {
180 let loader = WebHookLoader::new(config_path)?;
181 loader.fire_hook_result(name).await
182}
183
184
185const DISCORD_ATTACHMENT_THRESHOLD: usize = 1999; fn is_discord_webhook(url: &str) -> bool {
189 let u = url.to_ascii_lowercase();
190 u.contains("discord.com/api/webhooks") || u.contains("discordapp.com/api/webhooks")
191}
192
193async fn send_to_url_with_optional_body(url: &str, body_opt: Option<&str>) -> Result<(), WebHookError> {
194 let client = reqwest::Client::new();
195
196 let mut last_err: Option<String> = None;
198 for attempt in 1..=10 {
199 let req = if let Some(body) = body_opt {
201 if is_discord_webhook(url) && body.len() > DISCORD_ATTACHMENT_THRESHOLD {
202 let note = "Message too long; see attachment"; let payload_json = format!("{{\"content\":\"{}\"}}", note);
205 let file_part = match reqwest::multipart::Part::bytes(body.as_bytes().to_vec())
206 .file_name("message.txt")
207 .mime_str("text/plain; charset=utf-8")
208 {
209 Ok(p) => p,
210 Err(e) => {
211 last_err = Some(format!("failed to build multipart part: {e}"));
212 if attempt < 10 { tokio::time::sleep(Duration::from_millis(200 * attempt)).await; }
213 continue;
214 }
215 };
216 let form = reqwest::multipart::Form::new()
217 .text("payload_json", payload_json)
218 .part("file", file_part);
219 client.post(url).multipart(form)
220 } else {
221 let mut headers = HeaderMap::new();
223 let trimmed = body.trim_start();
224 let ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
225 "application/json"
226 } else {
227 "text/plain; charset=utf-8"
228 };
229 headers.insert(
230 CONTENT_TYPE,
231 HeaderValue::from_str(ct).unwrap_or(HeaderValue::from_static("text/plain; charset=utf-8")),
232 );
233 client.post(url).headers(headers).body(body.to_string())
234 }
235 } else {
236 client.post(url)
237 };
238
239 match req.send().await {
240 Ok(resp) => {
241 if resp.status().is_success() {
242 return Ok(());
243 } else {
244 let status = resp.status();
245 let body = resp.text().await.unwrap_or_default();
246 last_err = Some(format!("non-success status {status} body={}", body));
247 }
248 }
249 Err(e) => {
250 last_err = Some(format!("request error: {e}"));
251 }
252 }
253
254 if attempt < 10 {
255 tokio::time::sleep(Duration::from_millis(200 * attempt)).await;
256 }
257 }
258
259 Err(WebHookError::ExecutionError(
260 last_err.unwrap_or_else(|| "failed to send request".to_string()),
261 ))
262}
263
264pub async fn send_webhook_by_name_with_config_and_body(
265 config_path: &str,
266 name: &str,
267 body: &str,
268) -> Result<(), WebHookError> {
269 let loader = WebHookLoader::new(config_path)?;
270 let entry = loader
271 .hooks
272 .get(name)
273 .ok_or_else(|| WebHookError::ConfigError(format!("unknown webhook name: {name}")))?;
274 if entry.url.trim().is_empty() {
275 return Err(WebHookError::ConfigError("empty URL in config".into()));
276 }
277 let use_body = if body.is_empty() { entry.body.as_deref() } else { Some(body) };
279 send_to_url_with_optional_body(&entry.url, use_body).await
280}