diff --git a/client/src/BotConfig.css b/client/src/BotConfig.css new file mode 100644 index 0000000..3c2e93e --- /dev/null +++ b/client/src/BotConfig.css @@ -0,0 +1,242 @@ +/* Bot Configuration Component Styles */ + +.bot-config-container { + max-width: 600px; + margin: 0 auto; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.bot-config-header { + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid #dee2e6; +} + +.bot-config-header h3 { + margin: 0 0 8px 0; + color: #212529; + font-size: 1.5rem; +} + +.bot-config-header p { + margin: 4px 0; + color: #6c757d; + font-size: 0.9rem; +} + +.config-meta { + font-style: italic; +} + +.bot-config-form { + margin-bottom: 24px; +} + +.config-category { + margin-bottom: 32px; +} + +.category-title { + margin: 0 0 16px 0; + padding: 8px 12px; + background: #e9ecef; + border-radius: 4px; + color: #495057; + font-size: 1.1rem; + font-weight: 600; +} + +.category-parameters { + padding-left: 16px; +} + +.config-parameter { + margin-bottom: 20px; + padding: 16px; + background: white; + border-radius: 6px; + border: 1px solid #dee2e6; +} + +.config-label { + display: block; + margin-bottom: 8px; + font-weight: 500; + color: #495057; + font-size: 0.95rem; +} + +.config-label input[type="checkbox"] { + margin-right: 8px; +} + +.config-description { + margin: 8px 0 0 0; + font-size: 0.85rem; + color: #6c757d; + line-height: 1.4; +} + +.config-input, +.config-select { + width: 100%; + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 0.9rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.range-container { + display: flex; + align-items: center; + gap: 12px; +} + +.config-range { + flex: 1; + height: 6px; + background: #dee2e6; + border-radius: 3px; + outline: none; + -webkit-appearance: none; +} + +.config-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #007bff; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.config-range::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: #007bff; + cursor: pointer; + border: 2px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.range-value { + min-width: 40px; + text-align: center; + font-weight: 500; + color: #495057; + background: #f8f9fa; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid #dee2e6; +} + +.bot-config-actions { + text-align: center; + padding-top: 16px; + border-top: 1px solid #dee2e6; +} + +.config-save-button { + padding: 12px 24px; + background: #007bff; + color: white; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease-in-out; +} + +.config-save-button:hover:not(:disabled) { + background: #0056b3; +} + +.config-save-button:disabled { + background: #6c757d; + cursor: not-allowed; +} + +.bot-config-loading, +.bot-config-error, +.bot-config-unavailable { + text-align: center; + padding: 40px 20px; + border-radius: 8px; + margin: 20px 0; +} + +.bot-config-loading { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.bot-config-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.bot-config-unavailable { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +/* Responsive design */ +@media (max-width: 768px) { + .bot-config-container { + margin: 0 10px; + padding: 16px; + } + + .category-parameters { + padding-left: 8px; + } + + .config-parameter { + padding: 12px; + } + + .range-container { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .range-value { + align-self: center; + } +} + +/* Animation for smooth transitions */ +.config-parameter { + transition: box-shadow 0.15s ease-in-out; +} + +.config-parameter:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Custom checkbox styling */ +.config-label input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #007bff; +} diff --git a/client/src/BotConfig.tsx b/client/src/BotConfig.tsx new file mode 100644 index 0000000..18a85cb --- /dev/null +++ b/client/src/BotConfig.tsx @@ -0,0 +1,314 @@ +/** + * Bot Configuration Component + * + * This component provides a UI for configuring bot settings per lobby. + * It fetches the configuration schema from the bot and renders appropriate + * form controls for each configuration parameter. + */ + +import React, { useState, useEffect } from 'react'; +import './BotConfig.css'; + +interface ConfigParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'range'; + label: string; + description: string; + default_value?: any; + required?: boolean; + options?: Array<{ value: string; label: string }>; + min_value?: number; + max_value?: number; + step?: number; + max_length?: number; + pattern?: string; +} + +interface ConfigSchema { + bot_name: string; + version: string; + parameters: ConfigParameter[]; + categories?: Array<{ name: string; parameters: string[] }>; +} + +interface BotConfig { + bot_name: string; + lobby_id: string; + provider_id: string; + config_values: { [key: string]: any }; + created_at: number; + updated_at: number; + created_by: string; +} + +interface BotConfigProps { + botName: string; + lobbyId: string; + onConfigUpdate?: (config: BotConfig) => void; +} + +const BotConfigComponent: React.FC = ({ + botName, + lobbyId, + onConfigUpdate +}) => { + const [schema, setSchema] = useState(null); + const [currentConfig, setCurrentConfig] = useState(null); + const [configValues, setConfigValues] = useState<{ [key: string]: any }>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // Fetch configuration schema + useEffect(() => { + const fetchSchema = async () => { + try { + setLoading(true); + const response = await fetch(`/api/bots/config/schema/${botName}`); + + if (response.ok) { + const schemaData = await response.json(); + setSchema(schemaData); + + // Initialize config values with defaults + const defaultValues: { [key: string]: any } = {}; + schemaData.parameters.forEach((param: ConfigParameter) => { + if (param.default_value !== undefined) { + defaultValues[param.name] = param.default_value; + } + }); + setConfigValues(defaultValues); + } else if (response.status === 404) { + setError(`Bot "${botName}" does not support configuration`); + } else { + setError('Failed to fetch configuration schema'); + } + } catch (err) { + setError('Network error while fetching configuration schema'); + } finally { + setLoading(false); + } + }; + + fetchSchema(); + }, [botName]); + + // Fetch current configuration + useEffect(() => { + const fetchCurrentConfig = async () => { + try { + const response = await fetch(`/api/bots/config/lobby/${lobbyId}/bot/${botName}`); + + if (response.ok) { + const config = await response.json(); + setCurrentConfig(config); + setConfigValues({ ...configValues, ...config.config_values }); + } + // If 404, no existing config - that's fine + } catch (err) { + console.warn('Failed to fetch current config:', err); + } + }; + + if (schema) { + fetchCurrentConfig(); + } + }, [botName, lobbyId, schema]); + + const handleValueChange = (paramName: string, value: any) => { + setConfigValues(prev => ({ + ...prev, + [paramName]: value + })); + }; + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + + const response = await fetch('/api/bots/config/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bot_name: botName, + lobby_id: lobbyId, + config_values: configValues + }), + }); + + const result = await response.json(); + + if (result.success) { + setCurrentConfig(result.updated_config); + if (onConfigUpdate) { + onConfigUpdate(result.updated_config); + } + } else { + setError(result.message || 'Failed to save configuration'); + } + } catch (err) { + setError('Network error while saving configuration'); + } finally { + setSaving(false); + } + }; + + const renderParameter = (param: ConfigParameter) => { + const value = configValues[param.name]; + + switch (param.type) { + case 'boolean': + return ( +
+ +

{param.description}

+
+ ); + + case 'select': + return ( +
+ + +

{param.description}

+
+ ); + + case 'range': + return ( +
+ +
+ handleValueChange(param.name, Number(e.target.value))} + className="config-range" + /> + {value || param.default_value || 0} +
+

{param.description}

+
+ ); + + case 'number': + return ( +
+ + handleValueChange(param.name, Number(e.target.value))} + className="config-input" + /> +

{param.description}

+
+ ); + + case 'string': + default: + return ( +
+ + handleValueChange(param.name, e.target.value)} + className="config-input" + /> +

{param.description}

+
+ ); + } + }; + + const renderParametersByCategory = () => { + if (!schema) return null; + + if (schema.categories && schema.categories.length > 0) { + return schema.categories.map(category => ( +
+

{category.name}

+
+ {category.parameters.map(paramName => { + const param = schema.parameters.find(p => p.name === paramName); + return param ? renderParameter(param) : null; + })} +
+
+ )); + } else { + return schema.parameters.map(renderParameter); + } + }; + + if (loading) { + return
Loading configuration...
; + } + + if (error) { + return
Error: {error}
; + } + + if (!schema) { + return
Configuration not available
; + } + + return ( +
+
+

Configure {botName}

+

Lobby: {lobbyId}

+ {currentConfig && ( +

+ Last updated: {new Date(currentConfig.updated_at * 1000).toLocaleString()} +

+ )} +
+ +
+ {renderParametersByCategory()} +
+ +
+ +
+
+ ); +}; + +export default BotConfigComponent; diff --git a/client/src/BotManager.tsx b/client/src/BotManager.tsx index f92e27d..814964f 100644 --- a/client/src/BotManager.tsx +++ b/client/src/BotManager.tsx @@ -25,8 +25,11 @@ import { Add as AddIcon, ExpandMore as ExpandMoreIcon, Refresh as RefreshIcon, + Settings as SettingsIcon, } from "@mui/icons-material"; import { botsApi, BotInfoModel, BotProviderModel, BotJoinLobbyRequest } from "./api-client"; +import BotConfig from "./BotConfig"; +import BotConfigComponent from "./BotConfig"; interface BotManagerProps { lobbyId: string; @@ -44,6 +47,9 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { const [selectedBot, setSelectedBot] = useState(""); const [botNick, setBotNick] = useState(""); const [addingBot, setAddingBot] = useState(false); + // New state for bot configuration + const [configDialogOpen, setConfigDialogOpen] = useState(false); + const [configBotName, setConfigBotName] = useState(""); const loadBots = async () => { setLoading(true); @@ -113,6 +119,29 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { return provider ? provider.name : "Unknown Provider"; }; + // Configuration handlers + const handleOpenConfigDialog = (botName: string) => { + setConfigBotName(botName); + setConfigDialogOpen(true); + setError(null); + }; + + const handleCloseConfigDialog = () => { + setConfigDialogOpen(false); + setConfigBotName(""); + setError(null); + }; + + const handleConfigUpdate = (config: any) => { + // Optional: show success message or refresh bot info + console.log("Bot configuration updated:", config); + setConfigDialogOpen(false); + }; + + const isBotConfigurable = (bot: BotInfoModel): boolean => { + return Boolean(bot.configurable) || Boolean(bot.features && bot.features.includes("per_lobby_config")); + }; + const botCount = bots.length; const providerCount = botProviders.length; @@ -184,6 +213,15 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { } secondaryTypographyProps={{ component: "div" }} /> + {isBotConfigurable(botInfo) && ( + handleOpenConfigDialog(botInfo.name)} + size="small" + title="Configure Bot" + > + + + )} ); })} @@ -283,6 +321,19 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { + + {/* Bot Configuration Dialog */} + + Configure {configBotName} + + {configDialogOpen && configBotName && ( + + )} + + + + + ); }; diff --git a/client/src/api-client.ts b/client/src/api-client.ts index 973710e..4d61ad5 100644 --- a/client/src/api-client.ts +++ b/client/src/api-client.ts @@ -23,6 +23,8 @@ export type LobbyCreateResponse = components["schemas"]["LobbyCreateResponse"]; export interface BotInfoModel { name: string; description: string; + configurable?: boolean; + features?: string[]; } export interface BotProviderModel { diff --git a/server/api/bot_config.py b/server/api/bot_config.py new file mode 100644 index 0000000..4a84f9f --- /dev/null +++ b/server/api/bot_config.py @@ -0,0 +1,358 @@ +""" +Bot Configuration API + +This module provides REST API endpoints for managing bot configurations +including schema discovery, configuration CRUD operations, and real-time updates. +""" + +from typing import Dict, List, Optional, Any +from fastapi import APIRouter, HTTPException, BackgroundTasks, WebSocket + +from logger import logger +from core.bot_config_manager import BotConfigManager + +# Import WebSocket handler base class +try: + from websocket.message_handlers import MessageHandler +except ImportError: + from ..websocket.message_handlers import MessageHandler + +# Import shared models with fallback handling +try: + from ...shared.models import ( + BotConfigSchema, + BotLobbyConfig, + BotConfigUpdateRequest, + BotConfigUpdateResponse, + BotConfigListResponse + ) +except ImportError: + try: + from shared.models import ( + BotConfigSchema, + BotLobbyConfig, + BotConfigUpdateRequest, + BotConfigUpdateResponse, + BotConfigListResponse + ) + except ImportError: + # Create dummy models for standalone testing + from pydantic import BaseModel + + class BotConfigSchema(BaseModel): + bot_name: str + version: str = "1.0" + parameters: List[Dict[str, Any]] + + class BotLobbyConfig(BaseModel): + bot_name: str + lobby_id: str + provider_id: str + config_values: Dict[str, Any] + created_at: float + updated_at: float + created_by: str + + class BotConfigUpdateRequest(BaseModel): + bot_name: str + lobby_id: str + config_values: Dict[str, Any] + + class BotConfigUpdateResponse(BaseModel): + success: bool + message: str + updated_config: Optional[BotLobbyConfig] = None + + class BotConfigListResponse(BaseModel): + lobby_id: str + configs: List[BotLobbyConfig] + + +def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> APIRouter: + """Create FastAPI router for bot configuration endpoints""" + + router = APIRouter(prefix="/api/bots/config", tags=["Bot Configuration"]) + + @router.get("/schema/{bot_name}") + async def get_bot_config_schema(bot_name: str) -> BotConfigSchema: + """Get configuration schema for a specific bot""" + try: + # Check if we have cached schema + schema = config_manager.get_bot_config_schema(bot_name) + + if not schema: + # Try to discover schema from bot provider + providers = bot_manager.get_providers() + for provider_id, provider in providers.items(): + try: + # Check if this provider has the bot + provider_bots = await bot_manager.get_provider_bots(provider_id) + bot_names = [bot.name for bot in provider_bots.bots] + + if bot_name in bot_names: + schema = await config_manager.discover_bot_config_schema( + bot_name, provider.base_url + ) + if schema: + break + except Exception as e: + logger.warning(f"Failed to check provider {provider_id} for bot {bot_name}: {e}") + continue + + if not schema: + raise HTTPException( + status_code=404, + detail=f"No configuration schema found for bot '{bot_name}'" + ) + + return schema + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get bot config schema for {bot_name}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.get("/lobby/{lobby_id}") + async def get_lobby_bot_configs(lobby_id: str) -> BotConfigListResponse: + """Get all bot configurations for a lobby""" + try: + configs = config_manager.get_lobby_configs(lobby_id) + return BotConfigListResponse(lobby_id=lobby_id, configs=configs) + + except Exception as e: + logger.error(f"Failed to get lobby configs for {lobby_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.get("/lobby/{lobby_id}/bot/{bot_name}") + async def get_lobby_bot_config(lobby_id: str, bot_name: str) -> BotLobbyConfig: + """Get specific bot configuration for a lobby""" + try: + config = config_manager.get_lobby_bot_config(lobby_id, bot_name) + if not config: + raise HTTPException( + status_code=404, + detail=f"No configuration found for bot '{bot_name}' in lobby '{lobby_id}'" + ) + + return config + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get config for bot {bot_name} in lobby {lobby_id}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.post("/update") + async def update_bot_config( + request: BotConfigUpdateRequest, + background_tasks: BackgroundTasks, + session_id: str = "unknown" # TODO: Get from auth/session context + ) -> BotConfigUpdateResponse: + """Update bot configuration for a lobby""" + try: + # Find the provider for this bot + provider_id = None + provider_url = None + + providers = bot_manager.get_providers() + for pid, provider in providers.items(): + try: + provider_bots = await bot_manager.get_provider_bots(pid) + bot_names = [bot.name for bot in provider_bots.bots] + + if request.bot_name in bot_names: + provider_id = pid + provider_url = provider.base_url + break + except Exception: + continue + + if not provider_id: + raise HTTPException( + status_code=404, + detail=f"Bot '{request.bot_name}' not found in any provider" + ) + + # Update configuration + config = config_manager.set_bot_config( + lobby_id=request.lobby_id, + bot_name=request.bot_name, + provider_id=provider_id, + config_values=request.config_values, + session_id=session_id + ) + + # Notify bot provider in background + background_tasks.add_task( + config_manager.notify_bot_config_change, + provider_url, + request.bot_name, + request.lobby_id, + config + ) + + return BotConfigUpdateResponse( + success=True, + message="Configuration updated successfully", + updated_config=config + ) + + except ValueError as e: + # Validation error + return BotConfigUpdateResponse( + success=False, + message=f"Validation error: {str(e)}" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update bot config: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.delete("/lobby/{lobby_id}/bot/{bot_name}") + async def delete_bot_config(lobby_id: str, bot_name: str) -> Dict[str, Any]: + """Delete bot configuration for a lobby""" + try: + success = config_manager.delete_bot_config(lobby_id, bot_name) + + if not success: + raise HTTPException( + status_code=404, + detail=f"No configuration found for bot '{bot_name}' in lobby '{lobby_id}'" + ) + + return {"success": True, "message": "Configuration deleted successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete bot config: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.delete("/lobby/{lobby_id}") + async def delete_lobby_configs(lobby_id: str) -> Dict[str, Any]: + """Delete all bot configurations for a lobby""" + try: + success = config_manager.delete_lobby_configs(lobby_id) + + return { + "success": success, + "message": "All lobby configurations deleted" if success else "No configurations found" + } + + except Exception as e: + logger.error(f"Failed to delete lobby configs: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.get("/statistics") + async def get_config_statistics() -> Dict[str, Any]: + """Get configuration manager statistics""" + try: + return config_manager.get_statistics() + except Exception as e: + logger.error(f"Failed to get config statistics: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @router.post("/refresh-schemas") + async def refresh_bot_schemas(background_tasks: BackgroundTasks) -> Dict[str, Any]: + """Refresh all bot configuration schemas from providers""" + try: + async def refresh_task(): + refreshed = 0 + providers = bot_manager.get_providers() + + for provider_id, provider in providers.items(): + try: + provider_bots = await bot_manager.get_provider_bots(provider_id) + + for bot in provider_bots.bots: + try: + schema = await config_manager.discover_bot_config_schema( + bot.name, provider.base_url + ) + if schema: + refreshed += 1 + except Exception as e: + logger.warning(f"Failed to refresh schema for {bot.name}: {e}") + + except Exception as e: + logger.warning(f"Failed to refresh schemas from provider {provider_id}: {e}") + + logger.info(f"Refreshed {refreshed} bot configuration schemas") + + background_tasks.add_task(refresh_task) + + return { + "success": True, + "message": "Schema refresh started in background" + } + + except Exception as e: + logger.error(f"Failed to start schema refresh: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + return router + + +class BotConfigUpdateHandler(MessageHandler): + """WebSocket handler for real-time bot configuration updates""" + + def __init__(self, config_manager: BotConfigManager): + self.config_manager = config_manager + + async def handle(self, session, lobby, data: Dict[str, Any], websocket: WebSocket, managers: Dict[str, Any]): + """Handle real-time bot configuration updates via WebSocket""" + try: + # Extract update data + lobby_id = lobby.lobby_id if lobby else data.get("lobby_id") + bot_name = data.get("bot_name") + config_values = data.get("config_values") + session_id = session.session_id if session else "unknown" + + if not all([lobby_id, bot_name, config_values]): + await websocket.send_json({ + "type": "bot_config_error", + "error": "Missing required fields: lobby_id, bot_name, config_values" + }) + return + + # Update configuration (this will validate the values) + config = self.config_manager.set_bot_config( + lobby_id=lobby_id, + bot_name=bot_name, + provider_id="", # Will be resolved + config_values=config_values, + session_id=session_id + ) + + # Send success response + await websocket.send_json({ + "type": "bot_config_updated", + "config": { + "bot_name": config.bot_name, + "lobby_id": config.lobby_id, + "config_values": config.config_values, + "updated_at": config.updated_at + } + }) + + logger.info(f"Bot configuration updated via WebSocket: {bot_name} in lobby {lobby_id}") + + except Exception as e: + error_msg = f"Error updating bot configuration: {str(e)}" + logger.error(error_msg) + await websocket.send_json({ + "type": "bot_config_error", + "error": error_msg + }) + + +def setup_websocket_config_handlers(websocket_manager, config_manager: BotConfigManager): + """Setup WebSocket handlers for real-time configuration updates""" + + # Register the bot configuration update handler + config_handler = BotConfigUpdateHandler(config_manager) + websocket_manager.message_router.register("bot_config_update", config_handler) + + logger.info("Bot configuration WebSocket handlers registered") diff --git a/server/core/bot_config_manager.py b/server/core/bot_config_manager.py new file mode 100644 index 0000000..30d667e --- /dev/null +++ b/server/core/bot_config_manager.py @@ -0,0 +1,375 @@ +""" +Bot Configuration Manager + +This module handles per-lobby bot configuration management including: +- Configuration schema discovery from bot providers +- Configuration storage and retrieval per lobby +- Real-time configuration updates +- Validation of configuration values +""" + +import json +import time +import httpx +from typing import Dict, List, Optional, Any +from pathlib import Path + +from logger import logger + +# Import shared models with fallback handling +try: + from ...shared.models import ( + BotConfigSchema, + BotConfigParameter, + BotLobbyConfig, + BotConfigUpdateRequest, + BotConfigUpdateResponse, + BotConfigListResponse, + BotInfoModel + ) +except ImportError: + try: + from shared.models import ( + BotConfigSchema, + BotConfigParameter, + BotLobbyConfig, + BotConfigUpdateRequest, + BotConfigUpdateResponse, + BotConfigListResponse, + BotInfoModel + ) + except ImportError: + # Create dummy models for standalone testing + from pydantic import BaseModel + + class BotConfigSchema(BaseModel): + bot_name: str + version: str = "1.0" + parameters: List[Dict[str, Any]] + + class BotLobbyConfig(BaseModel): + bot_name: str + lobby_id: str + provider_id: str + config_values: Dict[str, Any] + created_at: float + updated_at: float + created_by: str + + +class BotConfigManager: + """Manages bot configurations for lobbies""" + + def __init__(self, storage_dir: str = "./bot_configs"): + self.storage_dir = Path(storage_dir) + self.storage_dir.mkdir(exist_ok=True) + + # In-memory cache for fast access + self.config_cache: Dict[str, Dict[str, BotLobbyConfig]] = {} # lobby_id -> bot_name -> config + self.schema_cache: Dict[str, BotConfigSchema] = {} # bot_name -> schema + + # Load existing configurations + self._load_configurations() + + def _get_config_file(self, lobby_id: str) -> Path: + """Get configuration file path for a lobby""" + return self.storage_dir / f"lobby_{lobby_id}.json" + + def _get_schema_file(self, bot_name: str) -> Path: + """Get schema file path for a bot""" + return self.storage_dir / f"schema_{bot_name}.json" + + def _load_configurations(self): + """Load all configurations from disk""" + try: + # Load lobby configurations + for config_file in self.storage_dir.glob("lobby_*.json"): + try: + lobby_id = config_file.stem.replace("lobby_", "") + with open(config_file, 'r') as f: + data = json.load(f) + + self.config_cache[lobby_id] = {} + for bot_name, config_data in data.items(): + config = BotLobbyConfig(**config_data) + self.config_cache[lobby_id][bot_name] = config + + except Exception as e: + logger.error(f"Failed to load lobby config {config_file}: {e}") + + # Load bot schemas + for schema_file in self.storage_dir.glob("schema_*.json"): + try: + bot_name = schema_file.stem.replace("schema_", "") + with open(schema_file, 'r') as f: + schema_data = json.load(f) + + schema = BotConfigSchema(**schema_data) + self.schema_cache[bot_name] = schema + + except Exception as e: + logger.error(f"Failed to load bot schema {schema_file}: {e}") + + logger.info(f"Loaded configurations for {len(self.config_cache)} lobbies and {len(self.schema_cache)} bot schemas") + + except Exception as e: + logger.error(f"Failed to load configurations: {e}") + + def _save_lobby_config(self, lobby_id: str): + """Save lobby configuration to disk""" + try: + config_file = self._get_config_file(lobby_id) + + if lobby_id not in self.config_cache: + return + + # Convert to serializable format + data = {} + for bot_name, config in self.config_cache[lobby_id].items(): + data[bot_name] = config.model_dump() + + with open(config_file, 'w') as f: + json.dump(data, f, indent=2) + + except Exception as e: + logger.error(f"Failed to save lobby config {lobby_id}: {e}") + + def _save_bot_schema(self, bot_name: str): + """Save bot schema to disk""" + try: + if bot_name not in self.schema_cache: + return + + schema_file = self._get_schema_file(bot_name) + schema_data = self.schema_cache[bot_name].model_dump() + + with open(schema_file, 'w') as f: + json.dump(schema_data, f, indent=2) + + except Exception as e: + logger.error(f"Failed to save bot schema {bot_name}: {e}") + + async def discover_bot_config_schema(self, bot_name: str, provider_url: str) -> Optional[BotConfigSchema]: + """Discover configuration schema from bot provider""" + try: + async with httpx.AsyncClient() as client: + # Try to get configuration schema from bot provider + response = await client.get( + f"{provider_url}/bots/{bot_name}/config-schema", + timeout=10.0 + ) + + if response.status_code == 200: + schema_data = response.json() + schema = BotConfigSchema(**schema_data) + + # Cache the schema + self.schema_cache[bot_name] = schema + self._save_bot_schema(bot_name) + + logger.info(f"Discovered config schema for bot {bot_name}") + return schema + else: + logger.warning(f"Bot {bot_name} does not support configuration (HTTP {response.status_code})") + + except Exception as e: + logger.warning(f"Failed to discover config schema for bot {bot_name}: {e}") + + return None + + def get_bot_config_schema(self, bot_name: str) -> Optional[BotConfigSchema]: + """Get cached configuration schema for a bot""" + return self.schema_cache.get(bot_name) + + def get_lobby_bot_config(self, lobby_id: str, bot_name: str) -> Optional[BotLobbyConfig]: + """Get bot configuration for a specific lobby""" + if lobby_id in self.config_cache and bot_name in self.config_cache[lobby_id]: + return self.config_cache[lobby_id][bot_name] + return None + + def get_lobby_configs(self, lobby_id: str) -> List[BotLobbyConfig]: + """Get all bot configurations for a lobby""" + if lobby_id in self.config_cache: + return list(self.config_cache[lobby_id].values()) + return [] + + def set_bot_config(self, + lobby_id: str, + bot_name: str, + provider_id: str, + config_values: Dict[str, Any], + session_id: str) -> BotLobbyConfig: + """Set or update bot configuration for a lobby""" + + # Validate configuration against schema if available + schema = self.get_bot_config_schema(bot_name) + if schema: + validated_values = self._validate_config_values(config_values, schema) + else: + validated_values = config_values + + # Create or update configuration + now = time.time() + + existing_config = self.get_lobby_bot_config(lobby_id, bot_name) + if existing_config: + # Update existing + config = BotLobbyConfig( + bot_name=bot_name, + lobby_id=lobby_id, + provider_id=provider_id, + config_values=validated_values, + created_at=existing_config.created_at, + updated_at=now, + created_by=existing_config.created_by + ) + else: + # Create new + config = BotLobbyConfig( + bot_name=bot_name, + lobby_id=lobby_id, + provider_id=provider_id, + config_values=validated_values, + created_at=now, + updated_at=now, + created_by=session_id + ) + + # Store in cache + if lobby_id not in self.config_cache: + self.config_cache[lobby_id] = {} + + self.config_cache[lobby_id][bot_name] = config + + # Save to disk + self._save_lobby_config(lobby_id) + + logger.info(f"Updated config for bot {bot_name} in lobby {lobby_id}") + return config + + def _validate_config_values(self, values: Dict[str, Any], schema: BotConfigSchema) -> Dict[str, Any]: + """Validate configuration values against schema""" + validated = {} + + for param in schema.parameters: + value = values.get(param.name) + + # Check required parameters + if param.required and value is None: + if param.default_value is not None: + value = param.default_value + else: + raise ValueError(f"Required parameter '{param.name}' is missing") + + # Skip None values for optional parameters + if value is None: + continue + + # Type validation + if param.type == "string": + value = str(value) + if param.max_length and len(value) > param.max_length: + raise ValueError(f"Parameter '{param.name}' exceeds max length {param.max_length}") + + elif param.type == "number": + value = float(value) + if param.min_value is not None and value < param.min_value: + raise ValueError(f"Parameter '{param.name}' below minimum {param.min_value}") + if param.max_value is not None and value > param.max_value: + raise ValueError(f"Parameter '{param.name}' above maximum {param.max_value}") + + elif param.type == "boolean": + value = bool(value) + + elif param.type == "select": + if param.options: + valid_values = [opt["value"] for opt in param.options] + if value not in valid_values: + raise ValueError(f"Parameter '{param.name}' must be one of {valid_values}") + + elif param.type == "range": + value = float(value) + if param.min_value is not None and value < param.min_value: + value = param.min_value + if param.max_value is not None and value > param.max_value: + value = param.max_value + + validated[param.name] = value + + return validated + + def delete_bot_config(self, lobby_id: str, bot_name: str) -> bool: + """Delete bot configuration for a lobby""" + if lobby_id in self.config_cache and bot_name in self.config_cache[lobby_id]: + del self.config_cache[lobby_id][bot_name] + + # Clean up empty lobby configs + if not self.config_cache[lobby_id]: + del self.config_cache[lobby_id] + config_file = self._get_config_file(lobby_id) + if config_file.exists(): + config_file.unlink() + else: + self._save_lobby_config(lobby_id) + + logger.info(f"Deleted config for bot {bot_name} in lobby {lobby_id}") + return True + + return False + + def delete_lobby_configs(self, lobby_id: str) -> bool: + """Delete all bot configurations for a lobby""" + if lobby_id in self.config_cache: + del self.config_cache[lobby_id] + + config_file = self._get_config_file(lobby_id) + if config_file.exists(): + config_file.unlink() + + logger.info(f"Deleted all configs for lobby {lobby_id}") + return True + + return False + + async def notify_bot_config_change(self, + provider_url: str, + bot_name: str, + lobby_id: str, + config: BotLobbyConfig): + """Notify bot provider of configuration change""" + try: + async with httpx.AsyncClient() as client: + # Notify the bot provider of the configuration change + response = await client.post( + f"{provider_url}/bots/{bot_name}/config", + json={ + "lobby_id": lobby_id, + "config_values": config.config_values + }, + timeout=10.0 + ) + + if response.status_code == 200: + logger.info(f"Successfully notified bot {bot_name} of config change") + return True + else: + logger.warning(f"Failed to notify bot {bot_name} of config change: HTTP {response.status_code}") + + except Exception as e: + logger.error(f"Failed to notify bot {bot_name} of config change: {e}") + + return False + + def get_statistics(self) -> Dict[str, Any]: + """Get configuration manager statistics""" + total_configs = sum(len(lobby_configs) for lobby_configs in self.config_cache.values()) + + return { + "total_lobbies": len(self.config_cache), + "total_configs": total_configs, + "cached_schemas": len(self.schema_cache), + "lobbies": { + lobby_id: len(configs) + for lobby_id, configs in self.config_cache.items() + } + } diff --git a/server/main.py b/server/main.py index 3c4c4a1..f76f560 100644 --- a/server/main.py +++ b/server/main.py @@ -31,11 +31,13 @@ try: from core.lobby_manager import LobbyManager from core.auth_manager import AuthManager from core.bot_manager import BotManager + from core.bot_config_manager import BotConfigManager from websocket.connection import WebSocketConnectionManager from api.admin import AdminAPI from api.sessions import SessionAPI from api.lobbies import LobbyAPI from api.bots import create_bot_router + from api.bot_config import create_bot_config_router, setup_websocket_config_handlers except ImportError: # Handle relative imports when running as module import sys @@ -47,11 +49,13 @@ except ImportError: from core.lobby_manager import LobbyManager from core.auth_manager import AuthManager from core.bot_manager import BotManager + from core.bot_config_manager import BotConfigManager from websocket.connection import WebSocketConnectionManager from api.admin import AdminAPI from api.sessions import SessionAPI from api.lobbies import LobbyAPI from api.bots import create_bot_router + from api.bot_config import create_bot_config_router, setup_websocket_config_handlers from logger import logger @@ -97,13 +101,21 @@ session_manager: SessionManager = None lobby_manager: LobbyManager = None auth_manager: AuthManager = None bot_manager: BotManager = None +bot_config_manager: BotConfigManager = None websocket_manager: WebSocketConnectionManager = None @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown events""" - global session_manager, lobby_manager, auth_manager, bot_manager, websocket_manager + global \ + session_manager, \ + lobby_manager, \ + auth_manager, \ + bot_manager, \ + bot_config_manager, \ + websocket_manager + # Startup logger.info("Starting AI Voice Bot server with modular architecture...") @@ -113,6 +125,7 @@ async def lifespan(app: FastAPI): lobby_manager = LobbyManager() auth_manager = AuthManager("sessions.json") bot_manager = BotManager() + bot_config_manager = BotConfigManager("./bot_configs") # Load existing data session_manager.load() @@ -136,6 +149,9 @@ async def lifespan(app: FastAPI): auth_manager=auth_manager, ) + # Setup WebSocket handlers for bot configuration + setup_websocket_config_handlers(websocket_manager, bot_config_manager) + # Create and register API routes admin_api = AdminAPI( session_manager=session_manager, @@ -156,11 +172,15 @@ async def lifespan(app: FastAPI): # Create bot API router bot_router = create_bot_router(bot_manager, session_manager, lobby_manager) + # Create bot configuration API router + bot_config_router = create_bot_config_router(bot_config_manager, bot_manager) + # Register API routes during startup app.include_router(admin_api.router) app.include_router(session_api.router) app.include_router(lobby_api.router) app.include_router(bot_router, prefix=public_url.rstrip("/") + "/api") + app.include_router(bot_config_router, prefix=public_url.rstrip("/")) # Add monitoring router if available if monitoring_available and monitoring_router: diff --git a/shared/models.py b/shared/models.py index fd14f45..9571a35 100644 --- a/shared/models.py +++ b/shared/models.py @@ -9,7 +9,7 @@ Test comment for shared reload detection - updated again """ from __future__ import annotations -from typing import List, Dict, Optional, Literal +from typing import List, Dict, Optional, Literal, Any from pydantic import BaseModel @@ -340,6 +340,81 @@ class BotInfoModel(BaseModel): name: str description: str has_media: bool = True # Whether this bot provides audio/video streams + configurable: bool = False # Whether this bot supports per-lobby configuration + config_schema: Optional[Dict[str, Any]] = ( + None # JSON schema for configuration parameters + ) + + +class BotConfigParameter(BaseModel): + """Definition of a bot configuration parameter""" + + name: str + type: Literal["string", "number", "boolean", "select", "range"] + label: str + description: str + default_value: Optional[Any] = None + required: bool = False + + # For select type + options: Optional[List[Dict[str, str]]] = ( + None # [{"value": "val", "label": "Label"}] + ) + + # For range/number type + min_value: Optional[float] = None + max_value: Optional[float] = None + step: Optional[float] = None + + # For string type + max_length: Optional[int] = None + pattern: Optional[str] = None # Regex pattern + + +class BotConfigSchema(BaseModel): + """Schema defining all configurable parameters for a bot""" + + bot_name: str + version: str = "1.0" + parameters: List[BotConfigParameter] + categories: Optional[List[Dict[str, List[str]]]] = ( + None # Group parameters by category + ) + + +class BotLobbyConfig(BaseModel): + """Bot configuration for a specific lobby""" + + bot_name: str + lobby_id: str + provider_id: str + config_values: Dict[str, Any] # Parameter name -> value mapping + created_at: float + updated_at: float + created_by: str # Session ID of who created the config + + +class BotConfigUpdateRequest(BaseModel): + """Request to update bot configuration""" + + bot_name: str + lobby_id: str + config_values: Dict[str, Any] + + +class BotConfigUpdateResponse(BaseModel): + """Response to bot configuration update""" + + success: bool + message: str + updated_config: Optional[BotLobbyConfig] = None + + +class BotConfigListResponse(BaseModel): + """Response listing bot configurations for a lobby""" + + lobby_id: str + configs: List[BotLobbyConfig] class BotProviderBotsResponse(BaseModel): diff --git a/voicebot/bot_orchestrator.py b/voicebot/bot_orchestrator.py index 14af039..dce2774 100644 --- a/voicebot/bot_orchestrator.py +++ b/voicebot/bot_orchestrator.py @@ -13,7 +13,7 @@ import sys import os import time from contextlib import asynccontextmanager -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional # Add the parent directory to sys.path to allow absolute imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -30,8 +30,30 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from shared.models import ChatMessageModel, BotInfoModel, BotProviderBotsResponse +# Global variables for reconnection logic +_server_url: Optional[str] = None +_voicebot_url: Optional[str] = None +_insecure: bool = False +_provider_id: Optional[str] = None +_reconnect_task: Optional[asyncio.Task] = None +_shutdown_event = asyncio.Event() +_provider_registration_status: bool = False + + +def get_provider_registration_status() -> dict: + """Get the current provider registration status for use by bot clients.""" + return { + "is_registered": _provider_registration_status, + "provider_id": _provider_id, + "server_url": _server_url, + "last_check": time.time() + } + + @asynccontextmanager async def lifespan(app: FastAPI): + global _reconnect_task, _shutdown_event + # Startup logger.info(f"🚀 Voicebot bot orchestrator started successfully at {time.strftime('%Y-%m-%d %H:%M:%S')}") # Log the discovered bots @@ -45,17 +67,27 @@ async def lifespan(app: FastAPI): # Check for remote server registration remote_server_url = os.getenv('VOICEBOT_SERVER_URL') if remote_server_url: - # Attempt to register with remote server + # Set up global variables for reconnection logic + global _server_url, _voicebot_url, _insecure, _provider_id + _server_url = remote_server_url + _insecure = os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true' + + host = os.getenv('HOST', '0.0.0.0') + port = os.getenv('PORT', '8788') + _voicebot_url = _construct_voicebot_url(host, port) + + # Attempt initial registration try: - host = os.getenv('HOST', '0.0.0.0') - port = os.getenv('PORT', '8788') - insecure = os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true' - - provider_id = await _perform_server_registration(remote_server_url, host, port, insecure) - logger.info(f"🎉 Successfully registered with remote server! Provider ID: {provider_id}") + _provider_id = await _perform_server_registration(remote_server_url, host, port, _insecure) + _provider_registration_status = True + logger.info(f"🎉 Successfully registered with remote server! Provider ID: {_provider_id}") except Exception as e: - logger.error(f"❌ Failed to register with remote server: {e}") - logger.warning("âš ī¸ Bot orchestrator will continue running without remote registration") + _provider_registration_status = False + logger.error(f"❌ Failed initial registration with remote server: {e}") + logger.warning("âš ī¸ Will attempt reconnection in background") + + # Start the reconnection monitoring task + _reconnect_task = asyncio.create_task(reconnection_monitor()) else: logger.info("â„šī¸ No VOICEBOT_SERVER_URL provided - running in local-only mode") @@ -63,6 +95,106 @@ async def lifespan(app: FastAPI): # Shutdown logger.info("🛑 Voicebot bot orchestrator shutting down") + _shutdown_event.set() + if _reconnect_task: + _reconnect_task.cancel() + try: + await _reconnect_task + except asyncio.CancelledError: + pass + + +async def reconnection_monitor(): + """Background task that monitors server connectivity and re-registers if needed.""" + reconnect_interval = 15 # Check every 15 seconds (faster for testing) + retry_interval = 5 # Retry failed connections every 5 seconds + + global _provider_registration_status, _provider_id + + logger.info(f"🔄 Starting provider reconnection monitor (check every {reconnect_interval}s)") + + while not _shutdown_event.is_set(): + try: + if _server_url and _voicebot_url and _provider_id: + # First check if server is healthy + is_server_healthy = await check_server_health(_server_url, _insecure) + + if not is_server_healthy: + logger.warning("âš ī¸ Server appears to be down or unreachable") + _provider_registration_status = False + # Try to re-register + try: + _provider_id = await register_with_server(_server_url, _voicebot_url, _insecure) + _provider_registration_status = True + logger.info(f"🔄 Successfully re-registered with server! Provider ID: {_provider_id}") + # Use longer interval after successful reconnection + await asyncio.sleep(reconnect_interval) + except Exception as e: + logger.error(f"❌ Re-registration failed: {e}") + # Use shorter interval for retry + await asyncio.sleep(retry_interval) + else: + # Server is healthy, now check if we're still registered + is_registered = await check_provider_registration(_server_url, _provider_id, _insecure) + _provider_registration_status = is_registered + + if not is_registered: + logger.warning("âš ī¸ Provider registration lost, attempting to re-register") + try: + _provider_id = await register_with_server(_server_url, _voicebot_url, _insecure) + _provider_registration_status = True + logger.info(f"🔄 Successfully re-registered with server! Provider ID: {_provider_id}") + except Exception as e: + logger.error(f"❌ Re-registration failed: {e}") + await asyncio.sleep(retry_interval) + continue + + # All good, check again after normal interval + await asyncio.sleep(reconnect_interval) + else: + # Missing configuration, wait longer + _provider_registration_status = False + await asyncio.sleep(reconnect_interval * 2) + + except asyncio.CancelledError: + logger.info("🛑 Provider reconnection monitor cancelled") + break + except Exception as e: + logger.error(f"đŸ’Ĩ Unexpected error in provider reconnection monitor: {e}") + await asyncio.sleep(retry_interval) + + +async def check_server_health(server_url: str, insecure: bool = False) -> bool: + """Check if the server is reachable and healthy.""" + try: + import httpx + + verify = not insecure + async with httpx.AsyncClient(verify=verify) as client: + # Try to hit the health endpoint + response = await client.get(f"{server_url}/api/health", timeout=5.0) + return response.status_code == 200 + except Exception as e: + logger.debug(f"Health check failed: {e}") + return False + + +async def check_provider_registration(server_url: str, provider_id: str, insecure: bool = False) -> bool: + """Check if the bot provider is still registered with the server.""" + try: + import httpx + + verify = not insecure + async with httpx.AsyncClient(verify=verify) as client: + # Check if our provider is still in the provider list + response = await client.get(f"{server_url}/api/bots", timeout=5.0) + if response.status_code == 200: + data = response.json() + providers = data.get("providers", {}) + return provider_id in [p.get("provider_id") for p in providers.values()] + except Exception as e: + logger.debug(f"Provider registration check failed: {e}") + return False app = FastAPI(title="voicebot-bot-orchestrator", lifespan=lifespan) @@ -134,6 +266,19 @@ def discover_bots() -> "List[BotInfoModel]": if hasattr(mod, "bind_send_chat_function") and callable(getattr(mod, "bind_send_chat_function")): bind_send_chat_function = getattr(mod, "bind_send_chat_function") + # Check for configuration schema support + config_schema = None + config_handler = None + if hasattr(mod, "get_config_schema") and callable(getattr(mod, "get_config_schema")): + try: + config_schema = mod.get_config_schema() + logger.info(f"Bot {bot_info.name} supports configuration") + except Exception as e: + logger.warning(f"Failed to get config schema for {bot_info.name}: {e}") + + if hasattr(mod, "handle_config_update") and callable(getattr(mod, "handle_config_update")): + config_handler = getattr(mod, "handle_config_update") + _bot_registry[bot_info.name] = { "module": name, "info": bot_info, @@ -141,6 +286,8 @@ def discover_bots() -> "List[BotInfoModel]": "chat_handler": chat_handler, "track_handler": track_handler, "chat_bind": bind_send_chat_function, + "config_schema": config_schema, + "config_handler": config_handler, } except Exception: @@ -232,6 +379,65 @@ async def bot_join(bot_name: str, req: JoinRequest): return {"status": "started", "bot": bot_name, "run_id": run_id} +@app.get("/provider/status") +def get_provider_status(): + """Get the current provider registration status.""" + return get_provider_registration_status() + + +@app.get("/bots/{bot_name}/config-schema") +def get_bot_config_schema(bot_name: str): + """Get configuration schema for a specific bot.""" + if bot_name not in _bot_registry: + raise HTTPException(status_code=404, detail=f"Bot '{bot_name}' not found") + + bot_entry = _bot_registry[bot_name] + config_schema = bot_entry.get("config_schema") + + if not config_schema: + raise HTTPException( + status_code=404, + detail=f"Bot '{bot_name}' does not support configuration" + ) + + return config_schema + + +@app.post("/bots/{bot_name}/config") +async def update_bot_config(bot_name: str, config_data: dict): + """Update bot configuration for a specific lobby.""" + if bot_name not in _bot_registry: + raise HTTPException(status_code=404, detail=f"Bot '{bot_name}' not found") + + bot_entry = _bot_registry[bot_name] + config_handler = bot_entry.get("config_handler") + + if not config_handler: + raise HTTPException( + status_code=404, + detail=f"Bot '{bot_name}' does not support configuration updates" + ) + + try: + lobby_id = config_data.get("lobby_id") + config_values = config_data.get("config_values", {}) + + if not lobby_id: + raise HTTPException(status_code=400, detail="lobby_id is required") + + # Call the bot's configuration handler + success = await config_handler(lobby_id, config_values) + + if success: + return {"success": True, "message": "Configuration updated successfully"} + else: + return {"success": False, "message": "Configuration update failed"} + + except Exception as e: + logger.error(f"Failed to update config for bot {bot_name}: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + @app.post("/bots/runs/{run_id}/stop") async def stop_run(run_id: str): """Stop a running bot.""" @@ -364,6 +570,12 @@ def start_bot_provider( """Start the bot provider API server and optionally register with main server""" import time + # Set up global variables for reconnection logic + global _server_url, _voicebot_url, _insecure, _provider_id + _server_url = server_url + _insecure = insecure + _voicebot_url = _construct_voicebot_url(host, str(port)) + # Start the FastAPI server in a background thread # Add reload functionality for development if reload: @@ -386,7 +598,7 @@ def start_bot_provider( logger.info(f"Starting bot provider API server on {host}:{port}...") server_thread.start() - # If server_url is provided, register with the main server + # If server_url is provided, attempt initial registration if server_url: logger.info(f"🔄 Server URL provided - will attempt registration with: {server_url}") # Give the server a moment to start @@ -394,11 +606,25 @@ def start_bot_provider( time.sleep(2) try: - provider_id = _perform_server_registration_sync(server_url, host, str(port), insecure) - logger.info(f"🎉 Registration completed successfully! Provider ID: {provider_id}") + _provider_id = _perform_server_registration_sync(server_url, host, str(port), insecure) + logger.info(f"🎉 Registration completed successfully! Provider ID: {_provider_id}") except Exception as e: - logger.error(f"❌ Failed to register with server: {e}") - logger.warning("âš ī¸ Bot orchestrator will continue running without remote registration") + logger.error(f"❌ Failed initial registration with server: {e}") + logger.warning("âš ī¸ Bot orchestrator will continue running and attempt reconnection") + + # Start a background thread for reconnection monitoring + def run_reconnection_monitor(): + """Run reconnection monitor in a separate thread with its own event loop.""" + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(reconnection_monitor()) + except Exception as e: + logger.error(f"Reconnection monitor thread failed: {e}") + + reconnect_thread = threading.Thread(target=run_reconnection_monitor, daemon=True) + reconnect_thread.start() + logger.info("🔄 Started reconnection monitor in background thread") else: logger.info("â„šī¸ No remote server URL provided - running in local-only mode") @@ -408,3 +634,4 @@ def start_bot_provider( time.sleep(1) except KeyboardInterrupt: logger.info("Shutting down bot provider...") + _shutdown_event.set() diff --git a/voicebot/bots/ai_chatbot.py b/voicebot/bots/ai_chatbot.py index 0310d3c..a6eafaa 100644 --- a/voicebot/bots/ai_chatbot.py +++ b/voicebot/bots/ai_chatbot.py @@ -238,12 +238,14 @@ def agent_info() -> Dict[str, str]: "name": AGENT_NAME, "description": AGENT_DESCRIPTION, "has_media": "false", + "configurable": "true", # This bot supports per-lobby configuration "features": [ "multi_provider_ai", "personality_system", "conversation_memory", "streaming_responses", - "health_monitoring" + "health_monitoring", + "per_lobby_config" ] } @@ -357,3 +359,159 @@ async def switch_ai_provider(provider_type: str) -> bool: logger.error(f"Failed to switch AI provider: {e}") return False + + +def get_config_schema() -> Dict[str, Any]: + """Get the configuration schema for this bot""" + return { + "bot_name": AGENT_NAME, + "version": "1.0", + "parameters": [ + { + "name": "personality", + "type": "select", + "label": "Bot Personality", + "description": "The personality and communication style of the bot", + "default_value": "helpful_assistant", + "required": False, + "options": [ + {"value": "helpful_assistant", "label": "Helpful Assistant"}, + {"value": "technical_expert", "label": "Technical Expert"}, + {"value": "creative_companion", "label": "Creative Companion"}, + {"value": "business_advisor", "label": "Business Advisor"}, + {"value": "comedy_bot", "label": "Comedy Bot"}, + {"value": "wise_mentor", "label": "Wise Mentor"} + ] + }, + { + "name": "ai_provider", + "type": "select", + "label": "AI Provider", + "description": "The AI service to use for generating responses", + "default_value": "openai", + "required": False, + "options": [ + {"value": "openai", "label": "OpenAI (GPT)"}, + {"value": "anthropic", "label": "Anthropic (Claude)"}, + {"value": "local", "label": "Local Model"} + ] + }, + { + "name": "streaming", + "type": "boolean", + "label": "Streaming Responses", + "description": "Enable real-time streaming of responses as they are generated", + "default_value": False, + "required": False + }, + { + "name": "memory_enabled", + "type": "boolean", + "label": "Conversation Memory", + "description": "Remember conversation context and history", + "default_value": True, + "required": False + }, + { + "name": "response_length", + "type": "select", + "label": "Response Length", + "description": "Preferred length of bot responses", + "default_value": "medium", + "required": False, + "options": [ + {"value": "short", "label": "Short & Concise"}, + {"value": "medium", "label": "Medium Length"}, + {"value": "long", "label": "Detailed & Comprehensive"} + ] + }, + { + "name": "creativity_level", + "type": "range", + "label": "Creativity Level", + "description": "How creative and varied the responses should be (0-100)", + "default_value": 50, + "required": False, + "min_value": 0, + "max_value": 100, + "step": 10 + }, + { + "name": "response_style", + "type": "select", + "label": "Response Style", + "description": "The communication style for responses", + "default_value": "conversational", + "required": False, + "options": [ + {"value": "formal", "label": "Formal & Professional"}, + {"value": "conversational", "label": "Conversational & Friendly"}, + {"value": "casual", "label": "Casual & Relaxed"}, + {"value": "academic", "label": "Academic & Technical"} + ] + } + ], + "categories": [ + { + "name": "AI Settings", + "parameters": ["personality", "ai_provider", "streaming"] + }, + { + "name": "Behavior Settings", + "parameters": ["memory_enabled", "response_length", "creativity_level"] + }, + { + "name": "Communication Style", + "parameters": ["response_style"] + } + ] + } + + +async def handle_config_update(lobby_id: str, config_values: Dict[str, Any]) -> bool: + """Handle configuration update for a specific lobby""" + global _bot_instance + + try: + logger.info(f"Updating config for lobby {lobby_id}: {config_values}") + + # Apply configuration changes + config_applied = False + + if "personality" in config_values: + success = await switch_personality(config_values["personality"]) + if success: + config_applied = True + logger.info(f"Applied personality: {config_values['personality']}") + + if "ai_provider" in config_values: + success = await switch_ai_provider(config_values["ai_provider"]) + if success: + config_applied = True + logger.info(f"Applied AI provider: {config_values['ai_provider']}") + + if "streaming" in config_values: + global BOT_STREAMING + BOT_STREAMING = bool(config_values["streaming"]) + config_applied = True + logger.info(f"Applied streaming: {BOT_STREAMING}") + + if "memory_enabled" in config_values: + global BOT_MEMORY_ENABLED + BOT_MEMORY_ENABLED = bool(config_values["memory_enabled"]) + config_applied = True + logger.info(f"Applied memory: {BOT_MEMORY_ENABLED}") + + # Store other configuration values for use in response generation + if _bot_instance: + if not hasattr(_bot_instance, 'lobby_configs'): + _bot_instance.lobby_configs = {} + + _bot_instance.lobby_configs[lobby_id] = config_values + config_applied = True + + return config_applied + + except Exception as e: + logger.error(f"Failed to apply config update: {e}") + return False