use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use thiserror::Error;
use toml::Value;
#[derive(Debug, Error)]
pub enum WebHookError {
#[error("config error: {0}")]
ConfigError(String),
#[error("execution error: {0}")]
ExecutionError(String),
}
#[derive(Debug, Clone)]
struct HookEntry {
url: String,
body: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WebHookLoader {
config_path: PathBuf,
hooks: HashMap<String, HookEntry>,
}
impl WebHookLoader {
pub fn new(config_path: &str) -> Result<Self, WebHookError> {
let path = PathBuf::from(config_path);
let content = fs::read_to_string(&path)
.map_err(|e| WebHookError::ConfigError(format!("failed to read config {config_path}: {e}")))?;
let hooks = parse_config(&content)?;
Ok(Self { config_path: path, hooks })
}
pub fn list_hooks(&self) -> Result<Vec<String>, WebHookError> {
let mut names: Vec<String> = self.hooks.keys().cloned().collect();
names.sort();
Ok(names)
}
pub async fn fire_hook_result(&self, name: &str) -> Result<(), WebHookError> {
let entry = self
.hooks
.get(name)
.ok_or_else(|| WebHookError::ConfigError(format!("unknown webhook name: {name}")))?;
if entry.url.trim().is_empty() {
return Err(WebHookError::ConfigError("empty URL in config".into()));
}
let client = reqwest::Client::new();
let url = &entry.url;
let body_opt = entry.body.as_ref();
let mut last_err: Option<String> = None;
for attempt in 1..=10 {
let mut req = client.post(url);
let mut headers = HeaderMap::new();
if let Some(body) = body_opt {
let trimmed = body.trim_start();
let ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
"application/json"
} else {
"text/plain; charset=utf-8"
};
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(ct).unwrap_or(HeaderValue::from_static("text/plain; charset=utf-8")),
);
req = req.headers(headers).body(body.clone());
} else {
req = req.headers(headers);
}
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
return Ok(());
} else {
let status = resp.status();
last_err = Some(format!("non-success status {status}"));
}
}
Err(e) => {
last_err = Some(format!("request error: {e}"));
}
}
if attempt < 10 {
tokio::time::sleep(Duration::from_millis(200 * attempt)).await;
}
}
Err(WebHookError::ExecutionError(
last_err.unwrap_or_else(|| "failed to send request".to_string()),
))
}
}
fn parse_config(content: &str) -> Result<HashMap<String, HookEntry>, WebHookError> {
let value: Value = toml::from_str(content)
.map_err(|e| WebHookError::ConfigError(format!("invalid TOML: {e}")))?;
let table = value
.as_table()
.ok_or_else(|| WebHookError::ConfigError("top-level TOML must be a table".into()))?;
let mut map: HashMap<String, HookEntry> = HashMap::new();
for (name, val) in table.iter() {
let s = val
.as_str()
.ok_or_else(|| WebHookError::ConfigError(format!("value for '{name}' must be a string")))?;
let (url, body) = split_url_body(s);
if url.trim().is_empty() {
return Err(WebHookError::ConfigError(format!("empty URL for '{name}'")));
}
map.insert(
name.clone(),
HookEntry {
url: url.to_string(),
body: body.map(|b| b.to_string()),
},
);
}
Ok(map)
}
fn split_url_body(s: &str) -> (&str, Option<&str>) {
if let Some(idx) = s.find(":::") {
let (u, rest) = s.split_at(idx);
let body = &rest[3..];
(u, Some(body))
} else {
(s, None)
}
}
fn default_config_path() -> String {
let mut default_path = "/infraqueue/config.toml".to_string();
let mut loop_count = 0;
while !Path::new(&default_path).exists() {
match loop_count {
0 => {
if let Ok(p) = std::env::var("WEBHOOKLOADER_CONFIG") {
if !p.trim().is_empty() {
default_path = p;
}
}
}
1 => {
default_path = "/etc/infraqueue/config.toml".to_string();
}
_ => {
default_path = "./config.toml".to_string();
break;
}
}
loop_count += 1;
}
println!("using config path: {}", default_path);
default_path
}
pub async fn send_webhook_by_name(name: &str) -> Result<(), WebHookError> {
let path = default_config_path();
send_webhook_by_name_with_config(&path, name).await
}
pub async fn send_webhook_by_name_with_config(
config_path: &str,
name: &str,
) -> Result<(), WebHookError> {
let loader = WebHookLoader::new(config_path)?;
loader.fire_hook_result(name).await
}
const DISCORD_ATTACHMENT_THRESHOLD: usize = 1999;
fn is_discord_webhook(url: &str) -> bool {
let u = url.to_ascii_lowercase();
u.contains("discord.com/api/webhooks") || u.contains("discordapp.com/api/webhooks")
}
async fn send_to_url_with_optional_body(url: &str, body_opt: Option<&str>) -> Result<(), WebHookError> {
let client = reqwest::Client::new();
let mut last_err: Option<String> = None;
for attempt in 1..=10 {
let req = if let Some(body) = body_opt {
if is_discord_webhook(url) && body.len() > DISCORD_ATTACHMENT_THRESHOLD {
let note = "Message too long; see attachment"; let payload_json = format!("{{\"content\":\"{}\"}}", note);
let file_part = match reqwest::multipart::Part::bytes(body.as_bytes().to_vec())
.file_name("message.txt")
.mime_str("text/plain; charset=utf-8")
{
Ok(p) => p,
Err(e) => {
last_err = Some(format!("failed to build multipart part: {e}"));
if attempt < 10 { tokio::time::sleep(Duration::from_millis(200 * attempt)).await; }
continue;
}
};
let form = reqwest::multipart::Form::new()
.text("payload_json", payload_json)
.part("file", file_part);
client.post(url).multipart(form)
} else {
let mut headers = HeaderMap::new();
let trimmed = body.trim_start();
let ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
"application/json"
} else {
"text/plain; charset=utf-8"
};
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(ct).unwrap_or(HeaderValue::from_static("text/plain; charset=utf-8")),
);
client.post(url).headers(headers).body(body.to_string())
}
} else {
client.post(url)
};
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
return Ok(());
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
last_err = Some(format!("non-success status {status} body={}", body));
}
}
Err(e) => {
last_err = Some(format!("request error: {e}"));
}
}
if attempt < 10 {
tokio::time::sleep(Duration::from_millis(200 * attempt)).await;
}
}
Err(WebHookError::ExecutionError(
last_err.unwrap_or_else(|| "failed to send request".to_string()),
))
}
pub async fn send_webhook_by_name_with_config_and_body(
config_path: &str,
name: &str,
body: &str,
) -> Result<(), WebHookError> {
let loader = WebHookLoader::new(config_path)?;
let entry = loader
.hooks
.get(name)
.ok_or_else(|| WebHookError::ConfigError(format!("unknown webhook name: {name}")))?;
if entry.url.trim().is_empty() {
return Err(WebHookError::ConfigError("empty URL in config".into()));
}
let use_body = if body.is_empty() { entry.body.as_deref() } else { Some(body) };
send_to_url_with_optional_body(&entry.url, use_body).await
}