use serde::{de, Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use std::fs;
use crate::error::{ButterflyBotError, Result};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OpenAiConfig {
pub api_key: Option<String>,
pub model: Option<String>,
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MemoryConfig {
pub enabled: Option<bool>,
pub sqlite_path: Option<String>,
pub summary_model: Option<String>,
pub embedding_model: Option<String>,
pub rerank_model: Option<String>,
pub openai: Option<OpenAiConfig>,
pub context_embed_enabled: Option<bool>,
pub summary_threshold: Option<usize>,
pub retention_days: Option<u32>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MarkdownSource {
Url { url: String },
Database { markdown: String },
}
impl MarkdownSource {
pub fn default_heartbeat() -> Self {
Self::Database {
markdown: "# Heartbeat\n\nStay proactive, grounded, and transparent. Prefer clear next steps and avoid over-claiming.".to_string(),
}
}
pub fn default_prompt() -> Self {
Self::Database {
markdown: "# Prompt\n\nAnswer directly, include concrete actions, and keep responses practical.".to_string(),
}
}
pub fn as_url(&self) -> Option<&str> {
match self {
Self::Url { url } => Some(url.as_str()),
Self::Database { .. } => None,
}
}
pub fn as_database_markdown(&self) -> Option<&str> {
match self {
Self::Url { .. } => None,
Self::Database { markdown } => Some(markdown.as_str()),
}
}
fn from_legacy_string(value: String) -> Self {
let trimmed = value.trim();
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
return Self::Url {
url: trimmed.to_string(),
};
}
let markdown = fs::read_to_string(trimmed).unwrap_or_default();
Self::Database { markdown }
}
fn from_json_value(value: Value) -> std::result::Result<Self, String> {
match value {
Value::String(raw) => Ok(Self::from_legacy_string(raw)),
Value::Object(map) => {
if let Some(kind) = map.get("type").and_then(|v| v.as_str()) {
match kind {
"url" => {
let url = map
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| "url source requires `url`".to_string())?;
Ok(Self::Url {
url: url.to_string(),
})
}
"database" => {
let markdown = map
.get("markdown")
.and_then(|v| v.as_str())
.unwrap_or_default();
Ok(Self::Database {
markdown: markdown.to_string(),
})
}
other => Err(format!("unsupported markdown source type: {other}")),
}
} else if let Some(url) = map.get("url").and_then(|v| v.as_str()) {
Ok(Self::Url {
url: url.to_string(),
})
} else if let Some(markdown) = map.get("markdown").and_then(|v| v.as_str()) {
Ok(Self::Database {
markdown: markdown.to_string(),
})
} else {
Err(
"markdown source object must include `type` or (`url`/`markdown`)"
.to_string(),
)
}
}
Value::Null => Err("markdown source cannot be null".to_string()),
other => Err(format!("invalid markdown source: {other}")),
}
}
}
impl<'de> Deserialize<'de> for MarkdownSource {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Self::from_json_value(value).map_err(de::Error::custom)
}
}
fn default_heartbeat_source() -> MarkdownSource {
MarkdownSource::default_heartbeat()
}
fn default_prompt_source() -> MarkdownSource {
MarkdownSource::default_prompt()
}
fn deserialize_heartbeat_source<'de, D>(
deserializer: D,
) -> std::result::Result<MarkdownSource, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(default_heartbeat_source()),
Some(value) => MarkdownSource::from_json_value(value).map_err(de::Error::custom),
}
}
fn deserialize_prompt_source<'de, D>(
deserializer: D,
) -> std::result::Result<MarkdownSource, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(default_prompt_source()),
Some(value) => MarkdownSource::from_json_value(value).map_err(de::Error::custom),
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub openai: Option<OpenAiConfig>,
#[serde(
default = "default_heartbeat_source",
alias = "heartbeat_file",
deserialize_with = "deserialize_heartbeat_source"
)]
pub heartbeat_source: MarkdownSource,
#[serde(
default = "default_prompt_source",
alias = "prompt_file",
deserialize_with = "deserialize_prompt_source"
)]
pub prompt_source: MarkdownSource,
pub memory: Option<MemoryConfig>,
pub tools: Option<Value>,
pub brains: Option<Value>,
}
impl Config {
fn apply_security_defaults(mut self) -> Self {
let tools = self.tools.get_or_insert_with(|| Value::Object(Map::new()));
if let Some(tools_obj) = tools.as_object_mut() {
let settings = tools_obj
.entry("settings")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(settings_obj) = settings.as_object_mut() {
let permissions = settings_obj
.entry("permissions")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(perms_obj) = permissions.as_object_mut() {
perms_obj
.entry("default_deny")
.or_insert_with(|| Value::Bool(true));
perms_obj.entry("network_allow").or_insert_with(|| {
Value::Array(vec![
Value::String("localhost".to_string()),
Value::String("127.0.0.1".to_string()),
Value::String("api.openai.com".to_string()),
Value::String("api.x.ai".to_string()),
Value::String("api.perplexity.ai".to_string()),
Value::String("api.githubcopilot.com".to_string()),
Value::String("mcp.zapier.com".to_string()),
])
});
}
}
}
self
}
pub fn convention_defaults(db_path: &str) -> Self {
let model = "ministral-3:14b".to_string();
Self {
openai: Some(OpenAiConfig {
api_key: None,
model: Some(model.clone()),
base_url: Some("http://localhost:11434/v1".to_string()),
}),
heartbeat_source: default_heartbeat_source(),
prompt_source: default_prompt_source(),
memory: Some(MemoryConfig {
enabled: Some(true),
sqlite_path: Some(db_path.to_string()),
summary_model: Some(model),
embedding_model: Some("embeddinggemma:latest".to_string()),
rerank_model: Some("qllama/bge-reranker-v2-m3".to_string()),
openai: None,
context_embed_enabled: Some(false),
summary_threshold: None,
retention_days: None,
}),
tools: Some(Value::Object(Map::new())),
brains: None,
}
.apply_security_defaults()
}
pub fn from_store(db_path: &str) -> Result<Self> {
match crate::config_store::load_config(db_path) {
Ok(config) => Ok(config.apply_security_defaults()),
Err(store_err) => {
if let Ok(Some(secret)) = crate::vault::get_secret("app_config_json") {
if !secret.trim().is_empty() {
let value: Value = serde_json::from_str(&secret)
.map_err(|e| ButterflyBotError::Config(e.to_string()))?;
let config: Config = serde_json::from_value(value)
.map_err(|e| ButterflyBotError::Config(e.to_string()))?;
return Ok(config.apply_security_defaults());
}
}
Err(store_err)
}
}
}
pub fn resolve_vault(mut self) -> Result<Self> {
if let Some(openai) = &mut self.openai {
if openai.api_key.is_none() {
if let Some(secret) = crate::vault::get_secret("openai_api_key")? {
openai.api_key = Some(secret);
}
}
}
if let Some(memory) = &mut self.memory {
if let Some(openai) = &mut memory.openai {
if openai.api_key.is_none() {
if let Some(secret) = crate::vault::get_secret("memory_openai_api_key")? {
openai.api_key = Some(secret);
}
}
}
}
Ok(self)
}
}