Skip to main content

infraqueue_webhookloader/
lib.rs

1use 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                // Choose Content-Type
65                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        // rest starts with ':::'
136        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
185// Added support for sending override bodies and Discord attachments when >1999 chars.
186const DISCORD_ATTACHMENT_THRESHOLD: usize = 1999; // Discord hard limit is 2000
187
188fn 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    // Retry loop similar to fire_hook_result
197    let mut last_err: Option<String> = None;
198    for attempt in 1..=10 {
199        // Decide request builder depending on URL/body
200        let req = if let Some(body) = body_opt {
201            if is_discord_webhook(url) && body.len() > DISCORD_ATTACHMENT_THRESHOLD {
202                // Build multipart with attachment for Discord
203                let note = "Message too long; see attachment"; // keep it short and safe
204                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                // Plain body with inferred Content-Type
222                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    // If provided body is empty, fall back to configured body if any
278    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}