butterfly-bot 0.3.2

Butterfly Bot is an opinionated personal-ops AI assistant built for people who want results, not setup overhead.
Documentation
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::interfaces::providers::LlmProvider;
use crate::providers::openai::OpenAiProvider;
use crate::vault;

#[derive(Clone, Debug)]
struct CodingConfig {
    api_key: Option<String>,
    model: String,
    base_url: String,
    system_prompt: String,
}

impl Default for CodingConfig {
    fn default() -> Self {
        Self {
            api_key: None,
            model: "gpt-5.2-codex".to_string(),
            base_url: "https://api.openai.com/v1".to_string(),
            system_prompt: "You are a senior coding agent. Focus on backend services (FastAPI/Python) and Solana smart contracts (Rust/Anchor). Provide precise, production-ready code changes with tests when applicable. Avoid UI and frontend work unless explicitly requested.".to_string(),
        }
    }
}

pub struct CodingTool {
    config: RwLock<CodingConfig>,
}

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

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

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

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

    fn description(&self) -> &str {
        "Use a dedicated coding model (Codex) for backend and smart contract work."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "prompt": { "type": "string", "description": "Coding task or request" },
                "system_prompt": { "type": "string", "description": "Optional system prompt override" }
            },
            "required": ["prompt"],
            "additionalProperties": false
        })
    }

    fn required_secrets_for_config(&self, config: &Value) -> Vec<ToolSecret> {
        let tool_cfg = match Self::get_tool_config(config) {
            Some(cfg) => cfg,
            None => return Vec::new(),
        };
        let has_key = tool_cfg.get("api_key").and_then(|v| v.as_str()).is_some();
        if has_key {
            Vec::new()
        } else {
            vec![ToolSecret::new(
                "coding_openai_api_key",
                "OpenAI API key (for coding tool)",
            )]
        }
    }

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

        if let Some(tool_cfg) = Self::get_tool_config(config) {
            if let Some(api_key) = tool_cfg.get("api_key").and_then(|v| v.as_str()) {
                if !api_key.trim().is_empty() {
                    next.api_key = Some(api_key.to_string());
                }
            }
            if let Some(model) = tool_cfg.get("model").and_then(|v| v.as_str()) {
                if !model.trim().is_empty() {
                    next.model = model.to_string();
                }
            }
            if let Some(base_url) = tool_cfg.get("base_url").and_then(|v| v.as_str()) {
                if !base_url.trim().is_empty() {
                    next.base_url = base_url.to_string();
                }
            }
            if let Some(system_prompt) = tool_cfg.get("system_prompt").and_then(|v| v.as_str()) {
                if !system_prompt.trim().is_empty() {
                    next.system_prompt = system_prompt.to_string();
                }
            }
        }

        if next.api_key.is_none() {
            if let Some(secret) = vault::get_secret("coding_openai_api_key")? {
                if !secret.trim().is_empty() {
                    next.api_key = Some(secret);
                }
            }
        }

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

    async fn execute(&self, params: Value) -> Result<Value> {
        let prompt = params
            .get("prompt")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ButterflyBotError::Runtime("Missing prompt".to_string()))?;

        let config = self.config.read().await.clone();
        let api_key = config
            .api_key
            .ok_or_else(|| ButterflyBotError::Runtime("Missing coding tool api_key".to_string()))?;

        let system_prompt = params
            .get("system_prompt")
            .and_then(|v| v.as_str())
            .unwrap_or(&config.system_prompt);

        let provider = OpenAiProvider::new(api_key, Some(config.model), Some(config.base_url));

        let response = provider.generate_text(prompt, system_prompt, None).await?;

        Ok(json!({"status": "ok", "response": response}))
    }
}