botserver/src/core/bot/manager.rs

1018 lines
31 KiB
Rust

//! Bot Manager Module
//!
//! Manages bot lifecycle including:
//! - Creating new bots from templates
//! - MinIO bucket creation (folder = bucket)
//! - Security/access assignment
//! - Custom UI routing (/botname/gbui)
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;
/// Bot configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotConfig {
/// Unique bot ID
pub id: Uuid,
/// Bot name (used in URLs: /botname)
pub name: String,
/// Display name
pub display_name: String,
/// Organization ID
pub org_id: Uuid,
/// Organization slug
pub org_slug: String,
/// Template used to create this bot
pub template: Option<String>,
/// Bot status
pub status: BotStatus,
/// MinIO bucket name
pub bucket: String,
/// Custom UI path (optional)
pub custom_ui: Option<String>,
/// Bot settings
pub settings: BotSettings,
/// Access control
pub access: BotAccess,
/// Creation timestamp
pub created_at: DateTime<Utc>,
/// Last updated timestamp
pub updated_at: DateTime<Utc>,
/// Created by user ID
pub created_by: Uuid,
}
/// Bot status
#[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"),
}
}
}
/// Bot settings
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BotSettings {
/// Default LLM model
pub llm_model: Option<String>,
/// Knowledge bases enabled
pub knowledge_bases: Vec<String>,
/// Enabled channels
pub channels: Vec<String>,
/// Webhook endpoints
pub webhooks: Vec<String>,
/// Schedule definitions
pub schedules: Vec<String>,
/// Custom variables
pub variables: HashMap<String, String>,
}
/// Bot access control
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BotAccess {
/// Admin users (full access)
pub admins: Vec<Uuid>,
/// Editor users (can edit scripts)
pub editors: Vec<Uuid>,
/// Viewer users (read-only)
pub viewers: Vec<Uuid>,
/// Public access enabled
pub is_public: bool,
/// Allowed domains (for embedding)
pub allowed_domains: Vec<String>,
/// API key for external access
pub api_key: Option<String>,
}
/// Available bot templates
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BotTemplate {
pub name: String,
pub display_name: String,
pub description: String,
pub category: String,
pub files: Vec<TemplateFile>,
pub preview_image: Option<String>,
}
/// Template file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateFile {
pub path: String,
pub content: String,
}
/// Bot creation request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateBotRequest {
/// Bot name (will be used in URLs)
pub name: String,
/// Display name
pub display_name: Option<String>,
/// Organization ID
pub org_id: Uuid,
/// Template to use (optional)
pub template: Option<String>,
/// Creator user ID
pub created_by: Uuid,
/// Initial settings
pub settings: Option<BotSettings>,
/// Custom UI name (optional)
pub custom_ui: Option<String>,
}
/// Bot Manager
pub struct BotManager {
/// MinIO client for bucket operations
minio_endpoint: String,
minio_access_key: String,
minio_secret_key: String,
/// Database connection string
database_url: String,
/// Templates directory
templates_dir: PathBuf,
/// Cached bots
bots_cache: Arc<RwLock<HashMap<Uuid, BotConfig>>>,
/// Available templates
templates: Arc<RwLock<HashMap<String, BotTemplate>>>,
}
impl BotManager {
/// Create a new 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())),
}
}
/// Initialize manager and load templates
pub async fn init(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Initializing Bot Manager...");
// Load available templates
self.load_templates().await?;
info!("Bot Manager initialized");
Ok(())
}
/// Load templates from templates directory
async fn load_templates(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut templates = self.templates.write().await;
// Built-in templates
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);
}
// Load templates from filesystem
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);
// Load template from filesystem directory
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(())
}
/// Load a template from a filesystem directory
fn load_template_from_directory(&self, path: &PathBuf, name: &str) -> Option<BotTemplate> {
// Check for template metadata file
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::<toml::Value>(&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)
};
// Check for dialogs
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::<Vec<_>>()
})
.unwrap_or_default()
} else {
Vec::new()
};
// Check for preview image
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,
})
}
/// Look up organization slug from database
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::<String>(&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()
}
}
}
/// Create a new bot
pub async fn create_bot(
&self,
request: CreateBotRequest,
conn: &DbPool,
) -> Result<BotConfig, Box<dyn std::error::Error + Send + Sync>> {
info!("Creating bot: {} for org: {}", request.name, request.org_id);
// Validate bot name
let bot_name = self.sanitize_bot_name(&request.name);
if bot_name.is_empty() {
return Err("Invalid bot name".into());
}
// Get org slug from database
let org_slug = self.get_org_slug_from_db(conn, request.org_id);
// Generate bucket name: org_botname
let bucket_name = format!("{}_{}", org_slug, bot_name);
// Create MinIO bucket
self.create_minio_bucket(&bucket_name).await?;
// Create bot configuration
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,
};
// Apply template if specified
if let Some(template_name) = &request.template {
self.apply_template(&bucket_name, template_name, &bot_name)
.await?;
} else {
// Create default directory structure
self.create_default_structure(&bucket_name, &bot_name)
.await?;
}
// Cache the bot
{
let mut cache = self.bots_cache.write().await;
cache.insert(bot_id, bot_config.clone());
}
// Update status to active
let mut bot_config = bot_config;
bot_config.status = BotStatus::Active;
info!("Bot created successfully: {} ({})", bot_name, bot_id);
Ok(bot_config)
}
/// Sanitize bot name for use in URLs and buckets
fn sanitize_bot_name(&self, name: &str) -> String {
name.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect::<String>()
.trim_matches(|c| c == '-' || c == '_')
.to_string()
}
/// Create MinIO bucket for bot
async fn create_minio_bucket(
&self,
bucket_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating MinIO bucket: {}", bucket_name);
// Use mc command to create bucket
// In production, use the AWS S3 SDK or minio-rs
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);
// Don't fail - bucket might be created via other means
}
}
// Set bucket policy (optional - make specific paths public if needed)
// mc admin policy attach local/ readwrite --user botuser
Ok(())
}
/// Create MinIO user for bot admin access
pub async fn create_bot_user(
&self,
username: &str,
password: &str,
bucket: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating MinIO user: {} for bucket: {}", username, bucket);
// Create user
// mc admin user add local/ username password
let _ = tokio::process::Command::new("mc")
.args(["admin", "user", "add", "local/", username, password])
.output()
.await;
// Create policy for bucket access
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)
]
}
]
});
// Write policy to temp file
let policy_path = format!("/tmp/policy_{}.json", bucket);
std::fs::write(&policy_path, policy.to_string())?;
// Create and attach policy
// mc admin policy create local/ policyname policy.json
let policy_name = format!("policy_{}", bucket);
let _ = tokio::process::Command::new("mc")
.args([
"admin",
"policy",
"create",
"local/",
&policy_name,
&policy_path,
])
.output()
.await;
// Attach policy to user
// mc admin policy attach local/ policyname --user username
let _ = tokio::process::Command::new("mc")
.args([
"admin",
"policy",
"attach",
"local/",
&policy_name,
"--user",
username,
])
.output()
.await;
// Clean up temp file
let _ = std::fs::remove_file(&policy_path);
info!("User created with bucket access: {}", username);
Ok(())
}
/// Apply template to bot bucket
async fn apply_template(
&self,
bucket: &str,
template_name: &str,
bot_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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 {
// Replace template variables
let content = file
.content
.replace("{{botname}}", bot_name)
.replace("{{platform}}", platform_name());
// Upload file to MinIO
self.upload_file(bucket, &file.path, content.as_bytes())
.await?;
}
info!(
"Applied template '{}' ({} files)",
template_name,
template.files.len()
);
Ok(())
}
/// Create default directory structure for bot
async fn create_default_structure(
&self,
bucket: &str,
bot_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Creating default structure in bucket: {}", bucket);
// Create directory markers (empty files with trailing /)
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?;
}
// Create default config
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?;
// Create default start script
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(())
}
/// Upload file to MinIO bucket
async fn upload_file(
&self,
bucket: &str,
path: &str,
content: &[u8],
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
debug!("Uploading to {}/{}", bucket, path);
// Write to temp file
let temp_path = format!("/tmp/upload_{}", Uuid::new_v4());
std::fs::write(&temp_path, content)?;
// Use mc to upload
let result = tokio::process::Command::new("mc")
.args(["cp", &temp_path, &format!("local/{}/{}", bucket, path)])
.output()
.await;
// Clean up
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);
// Fallback: write directly to filesystem if mc not available
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(())
}
/// Get available templates
pub async fn get_templates(&self) -> Vec<BotTemplate> {
let templates = self.templates.read().await;
templates.values().cloned().collect()
}
/// Get bot by ID
pub async fn get_bot(&self, bot_id: Uuid) -> Option<BotConfig> {
let cache = self.bots_cache.read().await;
cache.get(&bot_id).cloned()
}
/// Get bot by name and org
pub async fn get_bot_by_name(&self, org_slug: &str, bot_name: &str) -> Option<BotConfig> {
let cache = self.bots_cache.read().await;
cache
.values()
.find(|b| b.org_slug == org_slug && b.name == bot_name)
.cloned()
}
/// List bots for organization
pub async fn list_bots(&self, org_id: Uuid) -> Vec<BotConfig> {
let cache = self.bots_cache.read().await;
cache
.values()
.filter(|b| b.org_id == org_id)
.cloned()
.collect()
}
/// Delete bot
pub async fn delete_bot(
&self,
bot_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bot = self.get_bot(bot_id).await.ok_or("Bot not found")?;
info!("Deleting bot: {} ({})", bot.name, bot_id);
// Delete bucket contents
let _ = tokio::process::Command::new("mc")
.args([
"rm",
"--recursive",
"--force",
&format!("local/{}", bot.bucket),
])
.output()
.await;
// Delete bucket
let _ = tokio::process::Command::new("mc")
.args(["rb", &format!("local/{}", bot.bucket)])
.output()
.await;
// Remove from cache
{
let mut cache = self.bots_cache.write().await;
cache.remove(&bot_id);
}
info!("Bot deleted: {}", bot_id);
Ok(())
}
/// Get URL for bot
pub fn get_bot_url(&self, bot: &BotConfig, base_url: &str) -> String {
format!("{}/{}", base_url.trim_end_matches('/'), bot.name)
}
/// Get custom UI URL for bot
pub fn get_custom_ui_url(&self, bot: &BotConfig, base_url: &str) -> Option<String> {
bot.custom_ui.as_ref().map(|ui| {
format!(
"{}/{}/gbui/{}",
base_url.trim_end_matches('/'),
bot.name,
ui
)
})
}
}
/// Bot routing configuration for web server
#[derive(Debug, Clone)]
pub struct BotRoute {
/// Bot name (used in URL path)
pub name: String,
/// Organization slug
pub org_slug: String,
/// Full bucket path
pub bucket: String,
/// Custom UI path (if any)
pub custom_ui: Option<String>,
}
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(),
}
}
}