butterfly-bot 0.3.0

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
use std::collections::HashMap;
use std::time::Duration;

use async_trait::async_trait;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde_json::{json, Value};
use tokio::sync::RwLock;
use tracing::info;

use crate::error::{ButterflyBotError, Result};
use crate::interfaces::plugins::Tool;

#[derive(Clone, Debug, Default)]
struct HttpCallConfig {
    base_url: Option<String>,
    default_headers: HashMap<String, String>,
    timeout_seconds: Option<u64>,
}

pub struct HttpCallTool {
    config: RwLock<HttpCallConfig>,
}

impl Default for HttpCallTool {
    fn default() -> Self {
        Self::new()
    }
}

impl HttpCallTool {
    pub fn new() -> Self {
        Self {
            config: RwLock::new(HttpCallConfig::default()),
        }
    }

    fn build_headers(
        default_headers: &HashMap<String, String>,
        headers: Option<&Value>,
    ) -> Result<HeaderMap> {
        let mut out = HeaderMap::new();
        for (key, value) in default_headers {
            let header_name = key
                .parse::<reqwest::header::HeaderName>()
                .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
            let header_value = value
                .parse::<reqwest::header::HeaderValue>()
                .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
            out.insert(header_name, header_value);
        }
        if let Some(headers) = headers.and_then(|v| v.as_object()) {
            for (key, value) in headers {
                if let Some(value) = value.as_str() {
                    let header_name = key
                        .parse::<reqwest::header::HeaderName>()
                        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
                    let header_value = value
                        .parse::<reqwest::header::HeaderValue>()
                        .map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
                    out.insert(header_name, header_value);
                }
            }
        }
        Ok(out)
    }

    fn build_url(
        base_url: &Option<String>,
        url: Option<&str>,
        endpoint: Option<&str>,
    ) -> Result<String> {
        if let Some(url) = url {
            if !url.trim().is_empty() {
                return Ok(url.trim().to_string());
            }
        }
        let endpoint = endpoint.unwrap_or("").trim();
        if endpoint.is_empty() {
            return Err(ButterflyBotError::Runtime(
                "Missing url or endpoint".to_string(),
            ));
        }
        let base = base_url
            .as_ref()
            .map(|v| v.trim().trim_end_matches('/').to_string())
            .filter(|v| !v.is_empty())
            .ok_or_else(|| {
                ButterflyBotError::Runtime("Missing base_url for endpoint".to_string())
            })?;
        let endpoint = endpoint.trim_start_matches('/');
        Ok(format!("{base}/{endpoint}"))
    }

    fn apply_query(req: reqwest::RequestBuilder, query: Option<&Value>) -> reqwest::RequestBuilder {
        if let Some(map) = query.and_then(|v| v.as_object()) {
            let pairs: Vec<(String, String)> = map
                .iter()
                .map(|(k, v)| (k.clone(), v.as_str().unwrap_or(&v.to_string()).to_string()))
                .collect();
            return req.query(&pairs);
        }
        req
    }

    fn redact_headers(headers: &HeaderMap) -> HashMap<String, String> {
        headers
            .iter()
            .map(|(k, v)| {
                let key = k.to_string();
                let lower = key.to_ascii_lowercase();
                let value = if lower.contains("authorization")
                    || lower.contains("api-key")
                    || lower.contains("apikey")
                    || lower.contains("token")
                    || lower.contains("secret")
                {
                    "[REDACTED]".to_string()
                } else {
                    v.to_str().unwrap_or("").to_string()
                };
                (key, value)
            })
            .collect()
    }
}

#[async_trait]
impl Tool for HttpCallTool {
    fn name(&self) -> &str {
        "http_call"
    }

    fn description(&self) -> &str {
        "Perform arbitrary HTTP requests with custom headers and optional JSON/body payloads."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "method": { "type": "string" },
                "url": { "type": "string" },
                "endpoint": { "type": "string" },
                "headers": { "type": "object" },
                "query": { "type": "object" },
                "body": { "type": "string" },
                "json": { "type": "object" },
                "timeout_seconds": { "type": "integer" }
            },
            "required": ["method"]
        })
    }

    fn configure(&self, config: &Value) -> Result<()> {
        let tool_cfg = config.get("tools").and_then(|v| v.get("http_call"));
        let mut next = HttpCallConfig::default();
        if let Some(cfg) = tool_cfg {
            if let Some(base_url) = cfg.get("base_url").and_then(|v| v.as_str()) {
                let trimmed = base_url.trim();
                if !trimmed.is_empty() {
                    next.base_url = Some(trimmed.to_string());
                }
            }
            if let Some(headers) = cfg.get("default_headers").and_then(|v| v.as_object()) {
                next.default_headers = headers
                    .iter()
                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                    .collect();
            }
            if let Some(timeout) = cfg.get("timeout_seconds").and_then(|v| v.as_u64()) {
                next.timeout_seconds = Some(timeout);
            }
        }

        let mut guard = self
            .config
            .try_write()
            .map_err(|_| ButterflyBotError::Runtime("HTTP call tool lock busy".to_string()))?;
        *guard = next;
        Ok(())
    }

    async fn execute(&self, params: Value) -> Result<Value> {
        let method = params
            .get("method")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_uppercase();
        if method.is_empty() {
            return Err(ButterflyBotError::Runtime("Missing method".to_string()));
        }

        let url = params.get("url").and_then(|v| v.as_str());
        let endpoint = params.get("endpoint").and_then(|v| v.as_str());
        let headers = params.get("headers");
        let query = params.get("query");
        let body = params
            .get("body")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());
        let json_body = params.get("json").cloned();
        let timeout_override = params.get("timeout_seconds").and_then(|v| v.as_u64());

        let cfg = self.config.read().await.clone();
        let url = Self::build_url(&cfg.base_url, url, endpoint)?;
        let mut headers = Self::build_headers(&cfg.default_headers, headers)?;
        let mut body = body;
        let mut inferred_json: Option<Value> = None;
        if json_body.is_none() {
            if let Some(body_str) = body.as_deref() {
                if !headers.contains_key(CONTENT_TYPE) {
                    if let Ok(parsed) = serde_json::from_str::<Value>(body_str) {
                        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
                        inferred_json = Some(parsed);
                        body = None;
                    }
                }
            }
        }

        let client = reqwest::Client::new();
        let mut req = client.request(
            method
                .parse()
                .map_err(|_| ButterflyBotError::Runtime("Invalid method".to_string()))?,
            &url,
        );

        let redacted_headers = Self::redact_headers(&headers);
        if !headers.is_empty() {
            req = req.headers(headers);
        }
        req = Self::apply_query(req, query);

        if let Some(json_body) = json_body.and_then(|v| v.as_object().cloned()) {
            req = req.json(&json_body);
        } else if let Some(inferred_json) = inferred_json {
            req = req.json(&inferred_json);
        } else if let Some(body) = body {
            req = req.body(body);
        }

        let timeout = timeout_override.or(cfg.timeout_seconds).unwrap_or(60);
        req = req.timeout(Duration::from_secs(timeout));

        info!(
            method = %method,
            url = %url,
            headers = ?redacted_headers,
            "HTTP call request"
        );

        let response = req
            .send()
            .await
            .map_err(|e| ButterflyBotError::Http(e.to_string()))?;

        let status = response.status().as_u16();
        info!(
            method = %method,
            url = %url,
            status = %status,
            "HTTP call response"
        );
        let headers = response
            .headers()
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
            .collect::<HashMap<_, _>>();

        let text = response
            .text()
            .await
            .map_err(|e| ButterflyBotError::Http(e.to_string()))?;
        let json_value = serde_json::from_str::<Value>(&text).ok();

        Ok(json!({
            "status": "ok",
            "http_status": status,
            "headers": headers,
            "text": text,
            "json": json_value
        }))
    }
}