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, Add as AddIcon,
ExpandMore as ExpandMoreIcon, ExpandMore as ExpandMoreIcon,
Refresh as RefreshIcon, Refresh as RefreshIcon,
Settings as SettingsIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import { botsApi, BotInfoModel, BotProviderModel, BotJoinLobbyRequest } from "./api-client"; import { botsApi, BotInfoModel, BotProviderModel, BotJoinLobbyRequest } from "./api-client";
import BotConfig from "./BotConfig";
import BotConfigComponent from "./BotConfig";
interface BotManagerProps { interface BotManagerProps {
lobbyId: string; lobbyId: string;
@ -44,6 +47,9 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
const [selectedBot, setSelectedBot] = useState<string>(""); const [selectedBot, setSelectedBot] = useState<string>("");
const [botNick, setBotNick] = useState(""); const [botNick, setBotNick] = useState("");
const [addingBot, setAddingBot] = useState(false); const [addingBot, setAddingBot] = useState(false);
// New state for bot configuration
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [configBotName, setConfigBotName] = useState<string>("");
const loadBots = async () => { const loadBots = async () => {
setLoading(true); setLoading(true);
@ -113,6 +119,29 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
return provider ? provider.name : "Unknown Provider"; 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 botCount = bots.length;
const providerCount = botProviders.length; const providerCount = botProviders.length;
@ -184,6 +213,15 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
} }
secondaryTypographyProps={{ component: "div" }} secondaryTypographyProps={{ component: "div" }}
/> />
{isBotConfigurable(botInfo) && (
<IconButton
onClick={() => handleOpenConfigDialog(botInfo.name)}
size="small"
title="Configure Bot"
>
<SettingsIcon />
</IconButton>
)}
</ListItem> </ListItem>
); );
})} })}
@ -283,6 +321,19 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </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> </Paper>
); );
}; };

View File

@ -23,6 +23,8 @@ export type LobbyCreateResponse = components["schemas"]["LobbyCreateResponse"];
export interface BotInfoModel { export interface BotInfoModel {
name: string; name: string;
description: string; description: string;
configurable?: boolean;
features?: string[];
} }
export interface BotProviderModel { 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.lobby_manager import LobbyManager
from core.auth_manager import AuthManager from core.auth_manager import AuthManager
from core.bot_manager import BotManager from core.bot_manager import BotManager
from core.bot_config_manager import BotConfigManager
from websocket.connection import WebSocketConnectionManager from websocket.connection import WebSocketConnectionManager
from api.admin import AdminAPI from api.admin import AdminAPI
from api.sessions import SessionAPI from api.sessions import SessionAPI
from api.lobbies import LobbyAPI from api.lobbies import LobbyAPI
from api.bots import create_bot_router from api.bots import create_bot_router
from api.bot_config import create_bot_config_router, setup_websocket_config_handlers
except ImportError: except ImportError:
# Handle relative imports when running as module # Handle relative imports when running as module
import sys import sys
@ -47,11 +49,13 @@ except ImportError:
from core.lobby_manager import LobbyManager from core.lobby_manager import LobbyManager
from core.auth_manager import AuthManager from core.auth_manager import AuthManager
from core.bot_manager import BotManager from core.bot_manager import BotManager
from core.bot_config_manager import BotConfigManager
from websocket.connection import WebSocketConnectionManager from websocket.connection import WebSocketConnectionManager
from api.admin import AdminAPI from api.admin import AdminAPI
from api.sessions import SessionAPI from api.sessions import SessionAPI
from api.lobbies import LobbyAPI from api.lobbies import LobbyAPI
from api.bots import create_bot_router from api.bots import create_bot_router
from api.bot_config import create_bot_config_router, setup_websocket_config_handlers
from logger import logger from logger import logger
@ -97,13 +101,21 @@ session_manager: SessionManager = None
lobby_manager: LobbyManager = None lobby_manager: LobbyManager = None
auth_manager: AuthManager = None auth_manager: AuthManager = None
bot_manager: BotManager = None bot_manager: BotManager = None
bot_config_manager: BotConfigManager = None
websocket_manager: WebSocketConnectionManager = None websocket_manager: WebSocketConnectionManager = None
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events""" """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 # Startup
logger.info("Starting AI Voice Bot server with modular architecture...") logger.info("Starting AI Voice Bot server with modular architecture...")
@ -113,6 +125,7 @@ async def lifespan(app: FastAPI):
lobby_manager = LobbyManager() lobby_manager = LobbyManager()
auth_manager = AuthManager("sessions.json") auth_manager = AuthManager("sessions.json")
bot_manager = BotManager() bot_manager = BotManager()
bot_config_manager = BotConfigManager("./bot_configs")
# Load existing data # Load existing data
session_manager.load() session_manager.load()
@ -136,6 +149,9 @@ async def lifespan(app: FastAPI):
auth_manager=auth_manager, auth_manager=auth_manager,
) )
# Setup WebSocket handlers for bot configuration
setup_websocket_config_handlers(websocket_manager, bot_config_manager)
# Create and register API routes # Create and register API routes
admin_api = AdminAPI( admin_api = AdminAPI(
session_manager=session_manager, session_manager=session_manager,
@ -156,11 +172,15 @@ async def lifespan(app: FastAPI):
# Create bot API router # Create bot API router
bot_router = create_bot_router(bot_manager, session_manager, lobby_manager) 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 # Register API routes during startup
app.include_router(admin_api.router) app.include_router(admin_api.router)
app.include_router(session_api.router) app.include_router(session_api.router)
app.include_router(lobby_api.router) app.include_router(lobby_api.router)
app.include_router(bot_router, prefix=public_url.rstrip("/") + "/api") 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 # Add monitoring router if available
if monitoring_available and monitoring_router: 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 __future__ import annotations
from typing import List, Dict, Optional, Literal from typing import List, Dict, Optional, Literal, Any
from pydantic import BaseModel from pydantic import BaseModel
@ -340,6 +340,81 @@ class BotInfoModel(BaseModel):
name: str name: str
description: str description: str
has_media: bool = True # Whether this bot provides audio/video streams 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): class BotProviderBotsResponse(BaseModel):

View File

@ -13,7 +13,7 @@ import sys
import os import os
import time import time
from contextlib import asynccontextmanager 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 # Add the parent directory to sys.path to allow absolute imports
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
global _reconnect_task, _shutdown_event
# Startup # Startup
logger.info(f"🚀 Voicebot bot orchestrator started successfully at {time.strftime('%Y-%m-%d %H:%M:%S')}") logger.info(f"🚀 Voicebot bot orchestrator started successfully at {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Log the discovered bots # Log the discovered bots
@ -45,17 +67,27 @@ async def lifespan(app: FastAPI):
# Check for remote server registration # Check for remote server registration
remote_server_url = os.getenv('VOICEBOT_SERVER_URL') remote_server_url = os.getenv('VOICEBOT_SERVER_URL')
if remote_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: try:
host = os.getenv('HOST', '0.0.0.0') _provider_id = await _perform_server_registration(remote_server_url, host, port, _insecure)
port = os.getenv('PORT', '8788') _provider_registration_status = True
insecure = os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true' logger.info(f"🎉 Successfully registered with remote server! Provider ID: {_provider_id}")
provider_id = await _perform_server_registration(remote_server_url, host, port, insecure)
logger.info(f"🎉 Successfully registered with remote server! Provider ID: {provider_id}")
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to register with remote server: {e}") _provider_registration_status = False
logger.warning("⚠️ Bot orchestrator will continue running without remote registration") 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: else:
logger.info(" No VOICEBOT_SERVER_URL provided - running in local-only mode") logger.info(" No VOICEBOT_SERVER_URL provided - running in local-only mode")
@ -63,6 +95,106 @@ async def lifespan(app: FastAPI):
# Shutdown # Shutdown
logger.info("🛑 Voicebot bot orchestrator shutting down") 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) 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")): 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") 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] = { _bot_registry[bot_info.name] = {
"module": name, "module": name,
"info": bot_info, "info": bot_info,
@ -141,6 +286,8 @@ def discover_bots() -> "List[BotInfoModel]":
"chat_handler": chat_handler, "chat_handler": chat_handler,
"track_handler": track_handler, "track_handler": track_handler,
"chat_bind": bind_send_chat_function, "chat_bind": bind_send_chat_function,
"config_schema": config_schema,
"config_handler": config_handler,
} }
except Exception: 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} 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") @app.post("/bots/runs/{run_id}/stop")
async def stop_run(run_id: str): async def stop_run(run_id: str):
"""Stop a running bot.""" """Stop a running bot."""
@ -364,6 +570,12 @@ def start_bot_provider(
"""Start the bot provider API server and optionally register with main server""" """Start the bot provider API server and optionally register with main server"""
import time 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 # Start the FastAPI server in a background thread
# Add reload functionality for development # Add reload functionality for development
if reload: if reload:
@ -386,7 +598,7 @@ def start_bot_provider(
logger.info(f"Starting bot provider API server on {host}:{port}...") logger.info(f"Starting bot provider API server on {host}:{port}...")
server_thread.start() server_thread.start()
# If server_url is provided, register with the main server # If server_url is provided, attempt initial registration
if server_url: if server_url:
logger.info(f"🔄 Server URL provided - will attempt registration with: {server_url}") logger.info(f"🔄 Server URL provided - will attempt registration with: {server_url}")
# Give the server a moment to start # Give the server a moment to start
@ -394,11 +606,25 @@ def start_bot_provider(
time.sleep(2) time.sleep(2)
try: try:
provider_id = _perform_server_registration_sync(server_url, host, str(port), insecure) _provider_id = _perform_server_registration_sync(server_url, host, str(port), insecure)
logger.info(f"🎉 Registration completed successfully! Provider ID: {provider_id}") logger.info(f"🎉 Registration completed successfully! Provider ID: {_provider_id}")
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to register with server: {e}") logger.error(f"❌ Failed initial registration with server: {e}")
logger.warning("⚠️ Bot orchestrator will continue running without remote registration") 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: else:
logger.info(" No remote server URL provided - running in local-only mode") logger.info(" No remote server URL provided - running in local-only mode")
@ -408,3 +634,4 @@ def start_bot_provider(
time.sleep(1) time.sleep(1)
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("Shutting down bot provider...") logger.info("Shutting down bot provider...")
_shutdown_event.set()

View File

@ -238,12 +238,14 @@ def agent_info() -> Dict[str, str]:
"name": AGENT_NAME, "name": AGENT_NAME,
"description": AGENT_DESCRIPTION, "description": AGENT_DESCRIPTION,
"has_media": "false", "has_media": "false",
"configurable": "true", # This bot supports per-lobby configuration
"features": [ "features": [
"multi_provider_ai", "multi_provider_ai",
"personality_system", "personality_system",
"conversation_memory", "conversation_memory",
"streaming_responses", "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}") logger.error(f"Failed to switch AI provider: {e}")
return False 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