Improving bot configability

This commit is contained in:
James Ketr 2025-09-04 18:27:57 -07:00
parent 15641aa542
commit 095cca785d
10 changed files with 1840 additions and 18 deletions

242
client/src/BotConfig.css Normal file
View File

@ -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;
}

314
client/src/BotConfig.tsx Normal file
View File

@ -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<BotConfigProps> = ({
botName,
lobbyId,
onConfigUpdate
}) => {
const [schema, setSchema] = useState<ConfigSchema | null>(null);
const [currentConfig, setCurrentConfig] = useState<BotConfig | null>(null);
const [configValues, setConfigValues] = useState<{ [key: string]: any }>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div key={param.name} className="config-parameter">
<label className="config-label">
<input
type="checkbox"
checked={value || false}
onChange={(e) => handleValueChange(param.name, e.target.checked)}
/>
{param.label}
</label>
<p className="config-description">{param.description}</p>
</div>
);
case 'select':
return (
<div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label>
<select
value={value || ''}
onChange={(e) => handleValueChange(param.name, e.target.value)}
className="config-select"
>
{param.options?.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="config-description">{param.description}</p>
</div>
);
case 'range':
return (
<div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label>
<div className="range-container">
<input
type="range"
min={param.min_value || 0}
max={param.max_value || 100}
step={param.step || 1}
value={value || param.default_value || 0}
onChange={(e) => handleValueChange(param.name, Number(e.target.value))}
className="config-range"
/>
<span className="range-value">{value || param.default_value || 0}</span>
</div>
<p className="config-description">{param.description}</p>
</div>
);
case 'number':
return (
<div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label>
<input
type="number"
min={param.min_value}
max={param.max_value}
step={param.step || 1}
value={value || ''}
onChange={(e) => handleValueChange(param.name, Number(e.target.value))}
className="config-input"
/>
<p className="config-description">{param.description}</p>
</div>
);
case 'string':
default:
return (
<div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label>
<input
type="text"
maxLength={param.max_length}
pattern={param.pattern}
value={value || ''}
onChange={(e) => handleValueChange(param.name, e.target.value)}
className="config-input"
/>
<p className="config-description">{param.description}</p>
</div>
);
}
};
const renderParametersByCategory = () => {
if (!schema) return null;
if (schema.categories && schema.categories.length > 0) {
return schema.categories.map(category => (
<div key={category.name} className="config-category">
<h4 className="category-title">{category.name}</h4>
<div className="category-parameters">
{category.parameters.map(paramName => {
const param = schema.parameters.find(p => p.name === paramName);
return param ? renderParameter(param) : null;
})}
</div>
</div>
));
} else {
return schema.parameters.map(renderParameter);
}
};
if (loading) {
return <div className="bot-config-loading">Loading configuration...</div>;
}
if (error) {
return <div className="bot-config-error">Error: {error}</div>;
}
if (!schema) {
return <div className="bot-config-unavailable">Configuration not available</div>;
}
return (
<div className="bot-config-container">
<div className="bot-config-header">
<h3>Configure {botName}</h3>
<p>Lobby: {lobbyId}</p>
{currentConfig && (
<p className="config-meta">
Last updated: {new Date(currentConfig.updated_at * 1000).toLocaleString()}
</p>
)}
</div>
<div className="bot-config-form">
{renderParametersByCategory()}
</div>
<div className="bot-config-actions">
<button
onClick={handleSave}
disabled={saving}
className="config-save-button"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
</div>
);
};
export default BotConfigComponent;

View File

@ -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<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
const [selectedBot, setSelectedBot] = useState<string>("");
const [botNick, setBotNick] = useState("");
const [addingBot, setAddingBot] = useState(false);
// New state for bot configuration
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [configBotName, setConfigBotName] = useState<string>("");
const loadBots = async () => {
setLoading(true);
@ -113,6 +119,29 @@ const BotManager: React.FC<BotManagerProps> = ({ 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<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
}
secondaryTypographyProps={{ component: "div" }}
/>
{isBotConfigurable(botInfo) && (
<IconButton
onClick={() => handleOpenConfigDialog(botInfo.name)}
size="small"
title="Configure Bot"
>
<SettingsIcon />
</IconButton>
)}
</ListItem>
);
})}
@ -283,6 +321,19 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
</Button>
</DialogActions>
</Dialog>
{/* Bot Configuration Dialog */}
<Dialog open={configDialogOpen} onClose={handleCloseConfigDialog} maxWidth="md" fullWidth>
<DialogTitle>Configure {configBotName}</DialogTitle>
<DialogContent>
{configDialogOpen && configBotName && (
<BotConfig botName={configBotName} lobbyId={lobbyId} onConfigUpdate={handleConfigUpdate} />
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseConfigDialog}>Close</Button>
</DialogActions>
</Dialog>
</Paper>
);
};

View File

@ -23,6 +23,8 @@ export type LobbyCreateResponse = components["schemas"]["LobbyCreateResponse"];
export interface BotInfoModel {
name: string;
description: string;
configurable?: boolean;
features?: string[];
}
export interface BotProviderModel {

358
server/api/bot_config.py Normal file
View File

@ -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")

View File

@ -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()
}
}

View File

@ -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:

View File

@ -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):

View File

@ -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
try:
# 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')
insecure = os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true'
_voicebot_url = _construct_voicebot_url(host, port)
provider_id = await _perform_server_registration(remote_server_url, host, port, insecure)
logger.info(f"🎉 Successfully registered with remote server! Provider ID: {provider_id}")
# Attempt initial registration
try:
_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()

View File

@ -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