use config::{Config, ConfigError};
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use reqwest::StatusCode;
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rand::RngCore;
pub struct WebHookLoader {
pub hook_map: HashMap<String, String>,
}
#[derive(Debug)]
pub enum WebHookError {
ConfigError(ConfigError),
ExecutionError(String),
}
fn compute_discord_backoff(status: StatusCode, headers: &HeaderMap) -> Option<Duration> {
fn parse_secs(h: &HeaderMap, name: &str) -> Option<f64> {
h.get(name).and_then(|v| v.to_str().ok()).and_then(|s| s.trim().parse::<f64>().ok())
}
let mut rng = rand::rng();
let jitter_ms = 50 + (rng.next_u32() as u64 % 151);
let global = headers
.get("x-ratelimit-global")
.and_then(|v| v.to_str().ok())
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false);
if status == StatusCode::TOO_MANY_REQUESTS || global {
if let Some(secs) = parse_secs(headers, "retry-after") {
let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
return Some(Duration::from_millis(base_ms + jitter_ms));
}
if let Some(secs) = parse_secs(headers, "x-ratelimit-reset-after") {
let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
return Some(Duration::from_millis(base_ms + jitter_ms));
}
if let Some(reset_epoch) = parse_secs(headers, "x-ratelimit-reset") {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64();
let secs_left = (reset_epoch - now).max(0.0);
let base_ms = (secs_left * 1000.0).ceil() as u64;
return Some(Duration::from_millis(base_ms + jitter_ms));
}
return Some(Duration::from_millis(1000 + jitter_ms));
}
if let Some(remaining) = headers
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<i64>().ok())
{
if remaining <= 0 {
if let Some(secs) = parse_secs(headers, "x-ratelimit-reset-after") {
let base_ms = if secs <= 0.0 { 0 } else { (secs * 1000.0).ceil() as u64 };
return Some(Duration::from_millis(base_ms + jitter_ms));
}
if let Some(reset_epoch) = parse_secs(headers, "x-ratelimit-reset") {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64();
let secs_left = (reset_epoch - now).max(0.0);
let base_ms = (secs_left * 1000.0).ceil() as u64;
return Some(Duration::from_millis(base_ms + jitter_ms));
}
}
}
None
}
impl WebHookLoader {
pub fn new(config_path: &str) -> Result<WebHookLoader, WebHookError> {
let map = Config::builder()
.add_source(config::File::with_name(config_path))
.build()
.map_err(WebHookError::ConfigError)?;
let dsmap = map
.try_deserialize::<HashMap<String, String>>()
.map_err(WebHookError::ConfigError)?;
Ok(WebHookLoader { hook_map: dsmap })
}
pub fn list_hooks(&self) -> Result<Vec<String>, WebHookError> {
Ok(self.hook_map.keys().cloned().collect::<Vec<String>>())
}
pub async fn fire_hook_result(&self, name: &str) -> Result<(), WebHookError> {
let Some(value) = self.hook_map.get(name) else {
return Err(WebHookError::ExecutionError(format!(
"Webhook '{}' not found in config",
name
)));
};
let mut parts = value.splitn(2, ":::");
let url = parts.next().unwrap_or("").trim();
let body = parts.next().unwrap_or("").trim();
if url.is_empty() {
return Err(WebHookError::ExecutionError(
"Configured URL is empty".to_string(),
));
}
let content_type = if body.starts_with('{') || body.starts_with('[') {
"application/json"
} else {
"text/plain; charset=utf-8"
};
let client = reqwest::Client::new();
let tries = 10;
let mut counter = 0;
let mut last_err: Option<String> = None;
while counter < tries {
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(content_type).unwrap_or(HeaderValue::from_static("application/json")),
);
let req = client.post(url).headers(headers.clone());
let req = if body.is_empty() {
req
} else {
req.body(body.to_string())
};
match req.send().await {
Ok(resp) => {
let status = resp.status();
if status.is_success() {
if let Some(dur) = compute_discord_backoff(status, resp.headers()) {
tokio::time::sleep(dur).await;
}
return Ok(());
} else {
let backoff = compute_discord_backoff(status, resp.headers());
if let Some(dur) = backoff {
tokio::time::sleep(dur).await;
}
last_err = Some(format!(
"HTTP {} when POSTing to {}",
status, url
));
counter += 1;
}
}
Err(e) => {
if let Some(status) = e.status() {
if status == StatusCode::TOO_MANY_REQUESTS {
let mut rng = rand::rng();
let jitter_ms = 50 + (rng.next_u32() as u64 % 151);
tokio::time::sleep(Duration::from_millis(1000 + jitter_ms)).await;
}
}
last_err = Some(format!("{}", e));
counter += 1;
}
}
}
Err(WebHookError::ExecutionError(
last_err.unwrap_or_else(|| "Failed to send webhook after retries".to_string()),
))
}
pub async fn fire_hook(&self, name: &str) {
let _ = self.fire_hook_result(name).await;
}
}
#[cfg(test)]
mod tests {
use rand::RngCore;
use super::{WebHookLoader, compute_discord_backoff};
use crate::WebHookError;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::StatusCode;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
fn loader() -> Result<WebHookLoader, WebHookError> {
let config = r#"light_on = "http://localhost:8080/api/testme""#;
let mut rand = rand::rng();
let test_config = format!("/tmp/{}.toml", rand.next_u32());
if !Path::is_file(Path::new(&test_config)) {
if let Ok(mut file) = OpenOptions::new()
.append(true)
.create(true)
.open(&test_config)
{
file.write_all(config.as_bytes()).unwrap();
}
}
WebHookLoader::new(&test_config)
}
#[test]
fn test_webhook_loader() {
let new_whl = loader();
if let Ok(a) = &new_whl {
a.hook_map.iter().for_each(|(key, val)| {
println!("Key: {}, Value: {}", key, val);
})
}
assert!(new_whl.is_ok());
}
#[test]
fn test_backoff_429_retry_after_zero() {
let mut headers = HeaderMap::new();
headers.insert("retry-after", HeaderValue::from_static("0"));
let dur = compute_discord_backoff(StatusCode::TOO_MANY_REQUESTS, &headers).expect("should backoff");
assert!(dur.as_millis() as u64 >= 50 && dur.as_millis() as u64 <= 200);
}
#[test]
fn test_backoff_remaining_zero_reset_after() {
let mut headers = HeaderMap::new();
headers.insert("x-ratelimit-remaining", HeaderValue::from_static("0"));
headers.insert("x-ratelimit-reset-after", HeaderValue::from_static("0.2"));
let dur = compute_discord_backoff(StatusCode::BAD_REQUEST, &headers).expect("should backoff");
let ms = dur.as_millis() as u64;
assert!(ms >= 200 && ms <= 450, "ms={} not in expected range", ms);
}
#[test]
fn test_no_backoff_when_ok() {
let headers = HeaderMap::new();
let dur = compute_discord_backoff(StatusCode::OK, &headers);
assert!(dur.is_none());
}
}