use crate::core::shared::schema::organizations; use crate::shared::platform_name; use crate::shared::utils::DbPool; use chrono::{DateTime, Utc}; use diesel::prelude::*; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotConfig { pub id: Uuid, pub name: String, pub display_name: String, pub org_id: Uuid, pub org_slug: String, pub template: Option, pub status: BotStatus, pub bucket: String, pub custom_ui: Option, pub settings: BotSettings, pub access: BotAccess, pub created_at: DateTime, pub updated_at: DateTime, pub created_by: Uuid, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum BotStatus { Active, Inactive, Maintenance, Creating, Error, } impl std::fmt::Display for BotStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { BotStatus::Active => write!(f, "Active"), BotStatus::Inactive => write!(f, "Inactive"), BotStatus::Maintenance => write!(f, "Maintenance"), BotStatus::Creating => write!(f, "Creating"), BotStatus::Error => write!(f, "Error"), } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BotSettings { pub llm_model: Option, pub knowledge_bases: Vec, pub channels: Vec, pub webhooks: Vec, pub schedules: Vec, pub variables: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct BotAccess { pub admins: Vec, pub editors: Vec, pub viewers: Vec, pub is_public: bool, pub allowed_domains: Vec, pub api_key: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotTemplate { pub name: String, pub display_name: String, pub description: String, pub category: String, pub files: Vec, pub preview_image: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemplateFile { pub path: String, pub content: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateBotRequest { pub name: String, pub display_name: Option, pub org_id: Uuid, pub template: Option, pub created_by: Uuid, pub settings: Option, pub custom_ui: Option, } pub struct BotManager { minio_endpoint: String, minio_access_key: String, minio_secret_key: String, database_url: String, templates_dir: PathBuf, bots_cache: Arc>>, templates: Arc>>, } impl BotManager { pub fn new( minio_endpoint: &str, minio_access_key: &str, minio_secret_key: &str, database_url: &str, templates_dir: PathBuf, ) -> Self { Self { minio_endpoint: minio_endpoint.to_string(), minio_access_key: minio_access_key.to_string(), minio_secret_key: minio_secret_key.to_string(), database_url: database_url.to_string(), templates_dir, bots_cache: Arc::new(RwLock::new(HashMap::new())), templates: Arc::new(RwLock::new(HashMap::new())), } } pub async fn init(&self) -> Result<(), Box> { info!("Initializing Bot Manager..."); self.load_templates().await?; info!("Bot Manager initialized"); Ok(()) } async fn load_templates(&self) -> Result<(), Box> { let mut templates = self.templates.write().await; let builtin_templates = vec![ BotTemplate { name: "default".to_string(), display_name: "Default Bot".to_string(), description: "Basic bot with weather, email, and calculation tools".to_string(), category: "General".to_string(), files: vec![ TemplateFile { path: "default.gbdialog/start.bas".to_string(), content: r#"REM Default start script SET user_name = "Guest" TALK "Hello, " + user_name + "! How can I help you today?" HEAR user_input response = LLM "Respond helpfully to: " + user_input TALK response "# .to_string(), }, TemplateFile { path: "default.gbot/config.json".to_string(), content: r#"{ "name": "{{botname}}", "description": "Default bot created from template", "version": "1.0.0" }"# .to_string(), }, ], preview_image: None, }, BotTemplate { name: "crm".to_string(), display_name: "CRM Bot".to_string(), description: "Customer relationship management with lead scoring".to_string(), category: "Business".to_string(), files: vec![TemplateFile { path: "crm.gbdialog/lead.bas".to_string(), content: r#"REM Lead capture script PARAM name AS string LIKE "John Doe" PARAM email AS string LIKE "john@example.com" PARAM company AS string LIKE "Acme Inc" DESCRIPTION "Capture and score leads" TALK "Welcome! Let me help you get started." TALK "What's your name?" HEAR name TALK "And your email?" HEAR email TALK "What company are you from?" HEAR company score = AI SCORE LEAD email, company, "interested in our product" INSERT "leads", name, email, company, score, NOW() IF score > 80 THEN CREATE TASK "Hot lead: " + name, "sales", "today" TALK "Great! Our sales team will reach out shortly." ELSE TALK "Thanks for your interest! We'll send you some resources." SEND MAIL email, "Welcome!", "Thanks for reaching out..." END IF "# .to_string(), }], preview_image: None, }, BotTemplate { name: "edu".to_string(), display_name: "Education Bot".to_string(), description: "Course management and student enrollment".to_string(), category: "Education".to_string(), files: vec![TemplateFile { path: "edu.gbdialog/enroll.bas".to_string(), content: r#"REM Student enrollment script PARAM student_name AS string LIKE "Jane Student" PARAM course AS string LIKE "Introduction to AI" DESCRIPTION "Enroll students in courses" TALK "Welcome to our enrollment system!" TALK "What's your full name?" HEAR student_name TALK "Which course would you like to enroll in?" courses = FIND "courses", "status='open'" FOR EACH course IN courses TALK "- " + course.name NEXT HEAR selected_course INSERT "enrollments", student_name, selected_course, NOW() TALK "You're enrolled in " + selected_course + "!" SEND MAIL student_email, "Enrollment Confirmed", "Welcome to " + selected_course "# .to_string(), }], preview_image: None, }, BotTemplate { name: "store".to_string(), display_name: "E-commerce Bot".to_string(), description: "Product catalog and order management".to_string(), category: "Business".to_string(), files: vec![TemplateFile { path: "store.gbdialog/order.bas".to_string(), content: r#"REM Order management script DESCRIPTION "Help customers with orders" TALK "Welcome to our store! How can I help?" ADD SUGGESTION "Track my order" ADD SUGGESTION "Browse products" ADD SUGGESTION "Contact support" HEAR choice SWITCH choice CASE "Track my order" TALK "Please enter your order number:" HEAR order_id order = FIND "orders", "id=" + order_id TALK "Order status: " + order.status CASE "Browse products" products = FIND "products", "in_stock=true" TALK "Here are our available products:" FOR EACH product IN products TALK product.name + " - $" + product.price NEXT DEFAULT ticket = CREATE TASK choice, "support", "normal" TALK "Support ticket created: #" + ticket END SWITCH "# .to_string(), }], preview_image: None, }, BotTemplate { name: "hr".to_string(), display_name: "HR Assistant".to_string(), description: "Human resources and employee management".to_string(), category: "Business".to_string(), files: vec![TemplateFile { path: "hr.gbdialog/leave.bas".to_string(), content: r#"REM Leave request script DESCRIPTION "Handle employee leave requests" TALK "HR Assistant here. How can I help?" ADD SUGGESTION "Request leave" ADD SUGGESTION "Check balance" ADD SUGGESTION "View policies" HEAR request IF request = "Request leave" THEN TALK "What type of leave? (vacation/sick/personal)" HEAR leave_type TALK "Start date? (YYYY-MM-DD)" HEAR start_date TALK "End date? (YYYY-MM-DD)" HEAR end_date INSERT "leave_requests", user_id, leave_type, start_date, end_date, "pending" manager = FIND "employees", "id=" + user.manager_id TALK TO manager.email, "Leave request from " + user.name TALK "Leave request submitted! Your manager will review it." ELSE IF request = "Check balance" THEN balance = FIND "leave_balances", "user_id=" + user_id TALK "Your leave balance:" TALK "Vacation: " + balance.vacation + " days" TALK "Sick: " + balance.sick + " days" END IF "# .to_string(), }], preview_image: None, }, BotTemplate { name: "healthcare".to_string(), display_name: "Healthcare Bot".to_string(), description: "Appointment scheduling and patient management".to_string(), category: "Healthcare".to_string(), files: vec![TemplateFile { path: "healthcare.gbdialog/appointment.bas".to_string(), content: r#"REM Appointment scheduling DESCRIPTION "Schedule healthcare appointments" TALK "Welcome to our healthcare center. How can I help?" ADD SUGGESTION "Book appointment" ADD SUGGESTION "Cancel appointment" ADD SUGGESTION "View my appointments" HEAR choice IF choice = "Book appointment" THEN TALK "What type of appointment? (general/specialist/lab)" HEAR apt_type TALK "Preferred date? (YYYY-MM-DD)" HEAR pref_date available = FIND "slots", "date=" + pref_date + " AND type=" + apt_type TALK "Available times:" FOR EACH slot IN available TALK slot.time + " - Dr. " + slot.doctor NEXT TALK "Which time would you prefer?" HEAR selected_time BOOK apt_type + " appointment", selected_time, user.email TALK "Appointment booked! You'll receive a confirmation email." END IF "# .to_string(), }], preview_image: None, }, ]; for template in builtin_templates { templates.insert(template.name.clone(), template); } if self.templates_dir.exists() { if let Ok(entries) = std::fs::read_dir(&self.templates_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() && path.extension().map_or(false, |e| e == "gbai") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { if !templates.contains_key(name) { debug!("Found template directory: {}", name); if let Some(template) = self.load_template_from_directory(&path, name) { templates.insert(name.to_string(), template); info!("Loaded template from filesystem: {}", name); } } } } } } } info!("Loaded {} templates", templates.len()); Ok(()) } fn load_template_from_directory(&self, path: &PathBuf, name: &str) -> Option { let metadata_path = path.join("template.toml"); let description = if metadata_path.exists() { std::fs::read_to_string(&metadata_path) .ok() .and_then(|content| { toml::from_str::(&content).ok().and_then(|v| { v.get("description") .and_then(|d| d.as_str().map(String::from)) }) }) .unwrap_or_else(|| format!("Template loaded from {}", name)) } else { format!("Template loaded from {}", name) }; let dialog_dir = path.join(format!("{}.gbdialog", name)); let dialogs = if dialog_dir.exists() { std::fs::read_dir(&dialog_dir) .ok() .map(|entries| { entries .flatten() .filter(|e| e.path().extension().map_or(false, |ext| ext == "bas")) .filter_map(|e| { let file_name = e.file_name().to_string_lossy().to_string(); let content = std::fs::read_to_string(e.path()).ok()?; Some(DialogFile { name: file_name, content, }) }) .collect::>() }) .unwrap_or_default() } else { Vec::new() }; let preview_image = ["preview.png", "preview.jpg", "preview.svg"] .iter() .map(|f| path.join(f)) .find(|p| p.exists()) .and_then(|p| p.to_str().map(String::from)); Some(BotTemplate { name: name.to_string(), description, category: "Custom".to_string(), dialogs, preview_image, }) } fn get_org_slug_from_db(&self, conn: &DbPool, org_id: Uuid) -> String { let mut db_conn = match conn.get() { Ok(c) => c, Err(e) => { warn!("Failed to get database connection for org lookup: {}", e); return "default".to_string(); } }; let result = organizations::table .filter(organizations::org_id.eq(org_id)) .select(organizations::slug) .first::(&mut db_conn) .optional(); match result { Ok(Some(slug)) => { debug!("Found org slug '{}' for org_id {}", slug, org_id); slug } Ok(None) => { debug!("No org found for org_id {}, using 'default'", org_id); "default".to_string() } Err(e) => { warn!("Database error looking up org {}: {}", org_id, e); "default".to_string() } } } pub async fn create_bot( &self, request: CreateBotRequest, conn: &DbPool, ) -> Result> { info!("Creating bot: {} for org: {}", request.name, request.org_id); let bot_name = self.sanitize_bot_name(&request.name); if bot_name.is_empty() { return Err("Invalid bot name".into()); } let org_slug = self.get_org_slug_from_db(conn, request.org_id); let bucket_name = format!("{}_{}", org_slug, bot_name); self.create_minio_bucket(&bucket_name).await?; let bot_id = Uuid::new_v4(); let now = Utc::now(); let bot_config = BotConfig { id: bot_id, name: bot_name.clone(), display_name: request.display_name.unwrap_or_else(|| bot_name.clone()), org_id: request.org_id, org_slug: org_slug.to_string(), template: request.template.clone(), status: BotStatus::Creating, bucket: bucket_name.clone(), custom_ui: request.custom_ui, settings: request.settings.unwrap_or_default(), access: BotAccess { admins: vec![request.created_by], ..Default::default() }, created_at: now, updated_at: now, created_by: request.created_by, }; if let Some(template_name) = &request.template { self.apply_template(&bucket_name, template_name, &bot_name) .await?; } else { self.create_default_structure(&bucket_name, &bot_name) .await?; } { let mut cache = self.bots_cache.write().await; cache.insert(bot_id, bot_config.clone()); } let mut bot_config = bot_config; bot_config.status = BotStatus::Active; info!("Bot created successfully: {} ({})", bot_name, bot_id); Ok(bot_config) } fn sanitize_bot_name(&self, name: &str) -> String { name.to_lowercase() .chars() .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_') .collect::() .trim_matches(|c| c == '-' || c == '_') .to_string() } async fn create_minio_bucket( &self, bucket_name: &str, ) -> Result<(), Box> { info!("Creating MinIO bucket: {}", bucket_name); let output = tokio::process::Command::new("mc") .args(["mb", &format!("local/{}", bucket_name), "--ignore-existing"]) .output() .await; match output { Ok(result) => { if result.status.success() { info!("Bucket created: {}", bucket_name); } else { let stderr = String::from_utf8_lossy(&result.stderr); if !stderr.contains("already exists") { warn!("Bucket creation warning: {}", stderr); } } } Err(e) => { error!("Failed to create bucket: {}", e); } } Ok(()) } pub async fn create_bot_user( &self, username: &str, password: &str, bucket: &str, ) -> Result<(), Box> { info!("Creating MinIO user: {} for bucket: {}", username, bucket); let _ = tokio::process::Command::new("mc") .args(["admin", "user", "add", "local/", username, password]) .output() .await; let policy = serde_json::json!({ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket" ], "Resource": [ format!("arn:aws:s3:::{}", bucket), format!("arn:aws:s3:::{}/*", bucket) ] } ] }); let policy_path = format!("/tmp/policy_{}.json", bucket); std::fs::write(&policy_path, policy.to_string())?; let policy_name = format!("policy_{}", bucket); let _ = tokio::process::Command::new("mc") .args([ "admin", "policy", "create", "local/", &policy_name, &policy_path, ]) .output() .await; let _ = tokio::process::Command::new("mc") .args([ "admin", "policy", "attach", "local/", &policy_name, "--user", username, ]) .output() .await; let _ = std::fs::remove_file(&policy_path); info!("User created with bucket access: {}", username); Ok(()) } async fn apply_template( &self, bucket: &str, template_name: &str, bot_name: &str, ) -> Result<(), Box> { info!( "Applying template '{}' to bucket '{}'", template_name, bucket ); let templates = self.templates.read().await; let template = templates .get(template_name) .ok_or_else(|| format!("Template not found: {}", template_name))?; for file in &template.files { let content = file .content .replace("{{botname}}", bot_name) .replace("{{platform}}", platform_name()); self.upload_file(bucket, &file.path, content.as_bytes()) .await?; } info!( "Applied template '{}' ({} files)", template_name, template.files.len() ); Ok(()) } async fn create_default_structure( &self, bucket: &str, bot_name: &str, ) -> Result<(), Box> { info!("Creating default structure in bucket: {}", bucket); let dirs = [ format!("{}.gbdialog/", bot_name), format!("{}.gbkb/", bot_name), format!("{}.gbot/", bot_name), format!("{}.gbtheme/", bot_name), "uploads/".to_string(), "exports/".to_string(), "cache/".to_string(), ]; for dir in &dirs { self.upload_file(bucket, dir, b"").await?; } let config = serde_json::json!({ "name": bot_name, "version": "1.0.0", "created_at": Utc::now().to_rfc3339(), "platform": platform_name() }); self.upload_file( bucket, &format!("{}.gbot/config.json", bot_name), config.to_string().as_bytes(), ) .await?; let start_script = format!( r#"REM {} - Start Script TALK "Hello! I'm {}. How can I help you?" HEAR user_input response = LLM "Respond helpfully to: " + user_input TALK response "#, bot_name, bot_name ); self.upload_file( bucket, &format!("{}.gbdialog/start.bas", bot_name), start_script.as_bytes(), ) .await?; info!("Default structure created"); Ok(()) } async fn upload_file( &self, bucket: &str, path: &str, content: &[u8], ) -> Result<(), Box> { debug!("Uploading to {}/{}", bucket, path); let temp_path = format!("/tmp/upload_{}", Uuid::new_v4()); std::fs::write(&temp_path, content)?; let result = tokio::process::Command::new("mc") .args(["cp", &temp_path, &format!("local/{}/{}", bucket, path)]) .output() .await; let _ = std::fs::remove_file(&temp_path); match result { Ok(output) => { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); warn!("Upload warning: {}", stderr); } } Err(e) => { warn!("Upload failed (mc not available): {}", e); let fs_path = format!("./botserver-stack/minio/{}/{}", bucket, path); if let Some(parent) = std::path::Path::new(&fs_path).parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&fs_path, content)?; } } Ok(()) } pub async fn get_templates(&self) -> Vec { let templates = self.templates.read().await; templates.values().cloned().collect() } pub async fn get_bot(&self, bot_id: Uuid) -> Option { let cache = self.bots_cache.read().await; cache.get(&bot_id).cloned() } pub async fn get_bot_by_name(&self, org_slug: &str, bot_name: &str) -> Option { let cache = self.bots_cache.read().await; cache .values() .find(|b| b.org_slug == org_slug && b.name == bot_name) .cloned() } pub async fn list_bots(&self, org_id: Uuid) -> Vec { let cache = self.bots_cache.read().await; cache .values() .filter(|b| b.org_id == org_id) .cloned() .collect() } pub async fn delete_bot( &self, bot_id: Uuid, ) -> Result<(), Box> { let bot = self.get_bot(bot_id).await.ok_or("Bot not found")?; info!("Deleting bot: {} ({})", bot.name, bot_id); let _ = tokio::process::Command::new("mc") .args([ "rm", "--recursive", "--force", &format!("local/{}", bot.bucket), ]) .output() .await; let _ = tokio::process::Command::new("mc") .args(["rb", &format!("local/{}", bot.bucket)]) .output() .await; { let mut cache = self.bots_cache.write().await; cache.remove(&bot_id); } info!("Bot deleted: {}", bot_id); Ok(()) } pub fn get_bot_url(&self, bot: &BotConfig, base_url: &str) -> String { format!("{}/{}", base_url.trim_end_matches('/'), bot.name) } pub fn get_custom_ui_url(&self, bot: &BotConfig, base_url: &str) -> Option { bot.custom_ui.as_ref().map(|ui| { format!( "{}/{}/gbui/{}", base_url.trim_end_matches('/'), bot.name, ui ) }) } } #[derive(Debug, Clone)] pub struct BotRoute { pub name: String, pub org_slug: String, pub bucket: String, pub custom_ui: Option, } impl From<&BotConfig> for BotRoute { fn from(bot: &BotConfig) -> Self { BotRoute { name: bot.name.clone(), org_slug: bot.org_slug.clone(), bucket: bot.bucket.clone(), custom_ui: bot.custom_ui.clone(), } } }