butterfly-bot 0.8.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 async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::RwLock;

use crate::error::{ButterflyBotError, Result};
use crate::interfaces::plugins::{Tool, ToolSecret};
use crate::tools::mcp::McpTool;
use crate::vault;

#[derive(Clone, Debug)]
struct ZapierConfig {
    url: String,
    headers: HashMap<String, String>,
    token: Option<String>,
}

impl Default for ZapierConfig {
    fn default() -> Self {
        Self {
            url: "https://mcp.zapier.com/api/v1/connect".to_string(),
            headers: HashMap::new(),
            token: None,
        }
    }
}

pub struct ZapierTool {
    config: RwLock<ZapierConfig>,
}

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

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

    fn get_tool_config(config: &Value) -> Option<&Value> {
        config.get("tools").and_then(|tools| tools.get("zapier"))
    }

    fn parse_headers(value: &Value) -> HashMap<String, String> {
        value
            .as_object()
            .map(|map| {
                map.iter()
                    .filter_map(|(key, value)| value.as_str().map(|v| (key.clone(), v.to_string())))
                    .collect::<HashMap<String, String>>()
            })
            .unwrap_or_default()
    }

    fn has_token_in_url(url: &str) -> bool {
        url.contains("token=")
    }

    fn is_placeholder_token(token: &str) -> bool {
        let trimmed = token.trim();
        trimmed.is_empty()
            || trimmed.eq_ignore_ascii_case("my_token")
            || trimmed.eq_ignore_ascii_case("your_zapier_token")
            || trimmed.eq_ignore_ascii_case("token")
    }

    fn has_authorization_header(headers: &HashMap<String, String>) -> bool {
        headers
            .keys()
            .any(|key| key.eq_ignore_ascii_case("authorization"))
    }

    fn set_bearer_header(headers: &mut HashMap<String, String>, token: &str) {
        if !Self::has_authorization_header(headers) {
            headers.insert("Authorization".to_string(), format!("Bearer {token}"));
        }
    }

    fn token_from_url(url: &str) -> Option<String> {
        let query = url.split_once('?')?.1;
        for pair in query.split('&') {
            let (key, value) = pair.split_once('=')?;
            if key == "token" && !value.trim().is_empty() {
                return Some(value.to_string());
            }
        }
        None
    }

    fn has_valid_token_in_url(url: &str) -> bool {
        Self::token_from_url(url)
            .map(|token| !Self::is_placeholder_token(&token))
            .unwrap_or(false)
    }

    fn url_with_token(url: &str, token: &str) -> String {
        if Self::has_token_in_url(url) {
            return url.to_string();
        }
        let separator = if url.contains('?') { '&' } else { '?' };
        format!("{url}{separator}token={token}")
    }

    fn build_mcp_config(&self, config: &ZapierConfig) -> Value {
        json!({
            "tools": {
                "mcp": {
                    "servers": [
                        {
                            "name": "zapier",
                            "url": config.url.clone(),
                            "headers": config.headers.clone()
                        }
                    ]
                }
            }
        })
    }
}

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

    fn description(&self) -> &str {
        "Access Zapier MCP tools through a dedicated MCP endpoint wrapper."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["list_tools", "call_tool"]
                },
                "tool": { "type": "string", "description": "Zapier MCP tool name" },
                "arguments": { "type": "object", "description": "Arguments for the Zapier MCP tool" }
            },
            "required": ["action"],
            "additionalProperties": false
        })
    }

    fn required_secrets_for_config(&self, config: &Value) -> Vec<ToolSecret> {
        let Some(tool_cfg) = Self::get_tool_config(config) else {
            return Vec::new();
        };

        let url = tool_cfg
            .get("url")
            .and_then(|v| v.as_str())
            .unwrap_or("https://mcp.zapier.com/api/v1/connect");
        let has_inline_token = tool_cfg.get("token").and_then(|v| v.as_str()).is_some();
        let has_auth_header = tool_cfg
            .get("headers")
            .and_then(|v| v.as_object())
            .map(|headers| {
                headers
                    .keys()
                    .any(|key| key.eq_ignore_ascii_case("authorization"))
            })
            .unwrap_or(false);
        let has_valid_inline_token = tool_cfg
            .get("token")
            .and_then(|v| v.as_str())
            .map(|token| !Self::is_placeholder_token(token))
            .unwrap_or(false);

        if Self::has_valid_token_in_url(url)
            || has_valid_inline_token
            || (has_auth_header && !has_inline_token)
        {
            Vec::new()
        } else {
            vec![ToolSecret::new("zapier_token", "Zapier MCP token")]
        }
    }

    fn configure(&self, config: &Value) -> Result<()> {
        let mut next = ZapierConfig::default();

        if let Some(tool_cfg) = Self::get_tool_config(config) {
            if let Some(url) = tool_cfg.get("url").and_then(|v| v.as_str()) {
                if !url.trim().is_empty() {
                    next.url = url.to_string();
                }
            }
            if let Some(token) = tool_cfg.get("token").and_then(|v| v.as_str()) {
                if !Self::is_placeholder_token(token) {
                    next.token = Some(token.to_string());
                }
            }
            if let Some(headers) = tool_cfg.get("headers") {
                next.headers = Self::parse_headers(headers);
            }
        }

        if let Some(token) = next.token.clone() {
            next.url = Self::url_with_token(&next.url, &token);
            Self::set_bearer_header(&mut next.headers, &token);
        } else if let Some(token) = Self::token_from_url(&next.url) {
            if Self::is_placeholder_token(&token) {
                next.url = "https://mcp.zapier.com/api/v1/connect".to_string();
            } else {
                Self::set_bearer_header(&mut next.headers, &token);
            }
        }

        if !Self::has_valid_token_in_url(&next.url)
            && next
                .headers
                .get("Authorization")
                .map(|value| value.trim().is_empty())
                .unwrap_or(true)
        {
            next.url = "https://mcp.zapier.com/api/v1/connect".to_string();
        }

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

    async fn execute(&self, params: Value) -> Result<Value> {
        let action = params
            .get("action")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();

        let mut config = self.config.read().await.clone();

        if !Self::has_valid_token_in_url(&config.url)
            && !Self::has_authorization_header(&config.headers)
        {
            if let Some(secret) = vault::get_secret("zapier_token")? {
                if !Self::is_placeholder_token(&secret) {
                    config.url = Self::url_with_token(&config.url, &secret);
                    Self::set_bearer_header(&mut config.headers, &secret);
                }
            }
        }

        if !Self::has_valid_token_in_url(&config.url)
            && !Self::has_authorization_header(&config.headers)
        {
            return Err(ButterflyBotError::Runtime(
                "Missing Zapier token (set tools.zapier.token, tools.zapier.headers.Authorization, tools.zapier.url with token=..., or vault zapier_token)"
                    .to_string(),
            ));
        }

        let mcp_config = self.build_mcp_config(&config);
        let mcp_tool = McpTool::new();
        mcp_tool.configure(&mcp_config)?;

        match action.as_str() {
            "list_tools" => {
                let result = mcp_tool
                    .execute(json!({"action": "list_tools", "server": "zapier"}))
                    .await?;
                Ok(result)
            }
            "call_tool" => {
                let tool_name = params
                    .get("tool")
                    .and_then(|v| v.as_str())
                    .ok_or_else(|| ButterflyBotError::Runtime("Missing tool name".to_string()))?;
                let args = params.get("arguments").cloned();
                let result = mcp_tool
                    .execute(json!({
                        "action": "call_tool",
                        "server": "zapier",
                        "tool": tool_name,
                        "arguments": args
                    }))
                    .await?;
                Ok(result)
            }
            _ => Err(ButterflyBotError::Runtime("Unsupported action".to_string())),
        }
    }
}