infraqueue-webhookloader 0.1.1

Trigger webhooks by a simple name from a TOML mapping
Documentation
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 {
                // Choose Content-Type
                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);
        // rest starts with ':::'
        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
}


// Added support for sending override bodies and Discord attachments when >1999 chars.
const DISCORD_ATTACHMENT_THRESHOLD: usize = 1999; // Discord hard limit is 2000

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();

    // Retry loop similar to fire_hook_result
    let mut last_err: Option<String> = None;
    for attempt in 1..=10 {
        // Decide request builder depending on URL/body
        let req = if let Some(body) = body_opt {
            if is_discord_webhook(url) && body.len() > DISCORD_ATTACHMENT_THRESHOLD {
                // Build multipart with attachment for Discord
                let note = "Message too long; see attachment"; // keep it short and safe
                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 {
                // Plain body with inferred Content-Type
                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()));
    }
    // If provided body is empty, fall back to configured body if any
    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
}