Lots of tweaks

This commit is contained in:
James Ketr 2025-09-04 19:36:57 -07:00
parent 36548171d6
commit 71555c5230
13 changed files with 500 additions and 279 deletions

View File

@ -263,7 +263,9 @@ const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
))} */} ))} */}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />} {session && socketUrl && lobby && (
<UserList socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
)}
{session && socketUrl && lobby && ( {session && socketUrl && lobby && (
<LobbyChat socketUrl={socketUrl} session={session} lobbyId={lobby.id} /> <LobbyChat socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
)} )}

View File

@ -15,8 +15,15 @@
border-bottom: 1px solid #dee2e6; border-bottom: 1px solid #dee2e6;
} }
.bot-config-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.bot-config-header h3 { .bot-config-header h3 {
margin: 0 0 8px 0; margin: 0;
color: #212529; color: #212529;
font-size: 1.5rem; font-size: 1.5rem;
} }
@ -172,6 +179,30 @@
cursor: not-allowed; cursor: not-allowed;
} }
.config-refresh-button {
padding: 6px 12px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
display: flex;
align-items: center;
gap: 4px;
}
.config-refresh-button:hover:not(:disabled) {
background: #1e7e34;
}
.config-refresh-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.bot-config-loading, .bot-config-loading,
.bot-config-error, .bot-config-error,
.bot-config-unavailable { .bot-config-unavailable {

View File

@ -7,11 +7,12 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './BotConfig.css'; import { base } from "./Common";
import "./BotConfig.css";
interface ConfigParameter { interface ConfigParameter {
name: string; name: string;
type: 'string' | 'number' | 'boolean' | 'select' | 'range'; type: "string" | "number" | "boolean" | "select" | "range";
label: string; label: string;
description: string; description: string;
default_value?: any; default_value?: any;
@ -47,27 +48,35 @@ interface BotConfigProps {
onConfigUpdate?: (config: BotConfig) => void; onConfigUpdate?: (config: BotConfig) => void;
} }
const BotConfigComponent: React.FC<BotConfigProps> = ({ const BotConfigComponent: React.FC<BotConfigProps> = ({ botName, lobbyId, onConfigUpdate }) => {
botName,
lobbyId,
onConfigUpdate
}) => {
const [schema, setSchema] = useState<ConfigSchema | null>(null); const [schema, setSchema] = useState<ConfigSchema | null>(null);
const [currentConfig, setCurrentConfig] = useState<BotConfig | null>(null); const [currentConfig, setCurrentConfig] = useState<BotConfig | null>(null);
const [configValues, setConfigValues] = useState<{ [key: string]: any }>({}); const [configValues, setConfigValues] = useState<{ [key: string]: any }>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [refreshing, setRefreshing] = useState(false);
// Fetch configuration schema // Fetch configuration schema
useEffect(() => { const fetchSchema = async (forceRefresh = false) => {
const fetchSchema = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch(`/api/bots/config/schema/${botName}`); setError(null);
// Use refresh endpoint if force refresh is requested
const url = forceRefresh
? `${base}/api/bots/config/schema/${botName}/refresh`
: `${base}/api/bots/config/schema/${botName}`;
const method = forceRefresh ? "POST" : "GET";
const response = await fetch(url, { method });
if (response.ok) { if (response.ok) {
const schemaData = await response.json(); const responseData = await response.json();
// For refresh endpoint, the schema is in the 'schema' field
const schemaData = forceRefresh ? responseData.schema : responseData;
setSchema(schemaData); setSchema(schemaData);
// Initialize config values with defaults // Initialize config values with defaults
@ -81,23 +90,31 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
} else if (response.status === 404) { } else if (response.status === 404) {
setError(`Bot "${botName}" does not support configuration`); setError(`Bot "${botName}" does not support configuration`);
} else { } else {
setError('Failed to fetch configuration schema'); setError("Failed to fetch configuration schema");
} }
} catch (err) { } catch (err) {
setError('Network error while fetching configuration schema'); setError("Network error while fetching configuration schema");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => {
fetchSchema(); fetchSchema();
}, [botName]); }, [botName]);
// Handle schema refresh
const handleRefreshSchema = async () => {
setRefreshing(true);
await fetchSchema(true);
setRefreshing(false);
};
// Fetch current configuration // Fetch current configuration
useEffect(() => { useEffect(() => {
const fetchCurrentConfig = async () => { const fetchCurrentConfig = async () => {
try { try {
const response = await fetch(`/api/bots/config/lobby/${lobbyId}/bot/${botName}`); const response = await fetch(`${base}/api/bots/config/lobby/${lobbyId}/bot/${botName}`);
if (response.ok) { if (response.ok) {
const config = await response.json(); const config = await response.json();
@ -106,7 +123,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
} }
// If 404, no existing config - that's fine // If 404, no existing config - that's fine
} catch (err) { } catch (err) {
console.warn('Failed to fetch current config:', err); console.warn("Failed to fetch current config:", err);
} }
}; };
@ -116,9 +133,9 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
}, [botName, lobbyId, schema]); }, [botName, lobbyId, schema]);
const handleValueChange = (paramName: string, value: any) => { const handleValueChange = (paramName: string, value: any) => {
setConfigValues(prev => ({ setConfigValues((prev) => ({
...prev, ...prev,
[paramName]: value [paramName]: value,
})); }));
}; };
@ -127,15 +144,15 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
setSaving(true); setSaving(true);
setError(null); setError(null);
const response = await fetch('/api/bots/config/update', { const response = await fetch(`${base}/api/bots/config/update`, {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
bot_name: botName, bot_name: botName,
lobby_id: lobbyId, lobby_id: lobbyId,
config_values: configValues config_values: configValues,
}), }),
}); });
@ -147,10 +164,10 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
onConfigUpdate(result.updated_config); onConfigUpdate(result.updated_config);
} }
} else { } else {
setError(result.message || 'Failed to save configuration'); setError(result.message || "Failed to save configuration");
} }
} catch (err) { } catch (err) {
setError('Network error while saving configuration'); setError("Network error while saving configuration");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -160,7 +177,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
const value = configValues[param.name]; const value = configValues[param.name];
switch (param.type) { switch (param.type) {
case 'boolean': case "boolean":
return ( return (
<div key={param.name} className="config-parameter"> <div key={param.name} className="config-parameter">
<label className="config-label"> <label className="config-label">
@ -175,16 +192,16 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
</div> </div>
); );
case 'select': case "select":
return ( return (
<div key={param.name} className="config-parameter"> <div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label> <label className="config-label">{param.label}</label>
<select <select
value={value || ''} value={value || ""}
onChange={(e) => handleValueChange(param.name, e.target.value)} onChange={(e) => handleValueChange(param.name, e.target.value)}
className="config-select" className="config-select"
> >
{param.options?.map(option => ( {param.options?.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>
@ -194,7 +211,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
</div> </div>
); );
case 'range': case "range":
return ( return (
<div key={param.name} className="config-parameter"> <div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label> <label className="config-label">{param.label}</label>
@ -214,7 +231,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
</div> </div>
); );
case 'number': case "number":
return ( return (
<div key={param.name} className="config-parameter"> <div key={param.name} className="config-parameter">
<label className="config-label">{param.label}</label> <label className="config-label">{param.label}</label>
@ -223,7 +240,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
min={param.min_value} min={param.min_value}
max={param.max_value} max={param.max_value}
step={param.step || 1} step={param.step || 1}
value={value || ''} value={value || ""}
onChange={(e) => handleValueChange(param.name, Number(e.target.value))} onChange={(e) => handleValueChange(param.name, Number(e.target.value))}
className="config-input" className="config-input"
/> />
@ -231,7 +248,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
</div> </div>
); );
case 'string': case "string":
default: default:
return ( return (
<div key={param.name} className="config-parameter"> <div key={param.name} className="config-parameter">
@ -240,7 +257,7 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
type="text" type="text"
maxLength={param.max_length} maxLength={param.max_length}
pattern={param.pattern} pattern={param.pattern}
value={value || ''} value={value || ""}
onChange={(e) => handleValueChange(param.name, e.target.value)} onChange={(e) => handleValueChange(param.name, e.target.value)}
className="config-input" className="config-input"
/> />
@ -254,12 +271,12 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
if (!schema) return null; if (!schema) return null;
if (schema.categories && schema.categories.length > 0) { if (schema.categories && schema.categories.length > 0) {
return schema.categories.map(category => ( return schema.categories.map((category) => (
<div key={category.name} className="config-category"> <div key={category.name} className="config-category">
<h4 className="category-title">{category.name}</h4> <h4 className="category-title">{category.name}</h4>
<div className="category-parameters"> <div className="category-parameters">
{category.parameters.map(paramName => { {category.parameters.map((paramName) => {
const param = schema.parameters.find(p => p.name === paramName); const param = schema.parameters.find((p) => p.name === paramName);
return param ? renderParameter(param) : null; return param ? renderParameter(param) : null;
})} })}
</div> </div>
@ -285,26 +302,28 @@ const BotConfigComponent: React.FC<BotConfigProps> = ({
return ( return (
<div className="bot-config-container"> <div className="bot-config-container">
<div className="bot-config-header"> <div className="bot-config-header">
<div className="bot-config-title">
<h3>Configure {botName}</h3> <h3>Configure {botName}</h3>
<button
onClick={handleRefreshSchema}
disabled={refreshing || loading}
className="config-refresh-button"
title="Refresh configuration schema from bot"
>
{refreshing ? "⟳" : "↻"} Refresh Schema
</button>
</div>
<p>Lobby: {lobbyId}</p> <p>Lobby: {lobbyId}</p>
{currentConfig && ( {currentConfig && (
<p className="config-meta"> <p className="config-meta">Last updated: {new Date(currentConfig.updated_at * 1000).toLocaleString()}</p>
Last updated: {new Date(currentConfig.updated_at * 1000).toLocaleString()}
</p>
)} )}
</div> </div>
<div className="bot-config-form"> <div className="bot-config-form">{renderParametersByCategory()}</div>
{renderParametersByCategory()}
</div>
<div className="bot-config-actions"> <div className="bot-config-actions">
<button <button onClick={handleSave} disabled={saving} className="config-save-button">
onClick={handleSave} {saving ? "Saving..." : "Save Configuration"}
disabled={saving}
className="config-save-button"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button> </button>
</div> </div>
</div> </div>

View File

@ -2,12 +2,19 @@ import React, { useState, useEffect, useCallback } from "react";
import Paper from "@mui/material/Paper"; import Paper from "@mui/material/Paper";
import List from "@mui/material/List"; import List from "@mui/material/List";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import SettingsIcon from "@mui/icons-material/Settings";
import "./UserList.css"; import "./UserList.css";
import { MediaControl, MediaAgent, Peer } from "./MediaControl"; import { MediaControl, MediaAgent, Peer } from "./MediaControl";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { Session } from "./GlobalContext"; import { Session } from "./GlobalContext";
import useWebSocket from "react-use-websocket"; import useWebSocket from "react-use-websocket";
import { ApiClient, BotLeaveLobbyRequest } from "./api-client"; import { ApiClient, BotLeaveLobbyRequest } from "./api-client";
import BotConfig from "./BotConfig";
type User = { type User = {
name: string; name: string;
@ -24,13 +31,18 @@ type User = {
type UserListProps = { type UserListProps = {
socketUrl: string; socketUrl: string;
session: Session; session: Session;
lobbyId: string;
}; };
const UserList: React.FC<UserListProps> = (props: UserListProps) => { const UserList: React.FC<UserListProps> = (props: UserListProps) => {
const { socketUrl, session } = props; const { socketUrl, session, lobbyId } = props;
const [users, setUsers] = useState<User[] | null>(null); const [users, setUsers] = useState<User[] | null>(null);
const [peers, setPeers] = useState<Record<string, Peer>>({}); const [peers, setPeers] = useState<Record<string, Peer>>({});
const [videoClass, setVideoClass] = useState<string>("Large"); const [videoClass, setVideoClass] = useState<string>("Large");
// Bot configuration state
const [botConfigDialogOpen, setBotConfigDialogOpen] = useState(false);
const [selectedBotForConfig, setSelectedBotForConfig] = useState<User | null>(null);
const [leavingBots, setLeavingBots] = useState<Set<string>>(new Set()); const [leavingBots, setLeavingBots] = useState<Set<string>>(new Set());
const apiClient = new ApiClient(); const apiClient = new ApiClient();
@ -58,6 +70,17 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
} }
}; };
// Bot configuration handlers
const handleOpenBotConfig = (user: User) => {
setSelectedBotForConfig(user);
setBotConfigDialogOpen(true);
};
const handleCloseBotConfig = () => {
setBotConfigDialogOpen(false);
setSelectedBotForConfig(null);
};
const sortUsers = useCallback( const sortUsers = useCallback(
(A: any, B: any) => { (A: any, B: any) => {
if (!session) { if (!session) {
@ -155,6 +178,17 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
)} )}
</div> </div>
{user.is_bot && !user.local && ( {user.is_bot && !user.local && (
<div style={{ display: "flex", gap: "4px" }}>
{user.bot_run_id && (
<IconButton
size="small"
onClick={() => handleOpenBotConfig(user)}
style={{ width: "24px", height: "24px", fontSize: "0.7em" }}
title="Configure bot"
>
<SettingsIcon style={{ fontSize: "14px" }} />
</IconButton>
)}
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
@ -165,6 +199,7 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
> >
{leavingBots.has(user.session_id) ? "..." : "Leave"} {leavingBots.has(user.session_id) ? "..." : "Leave"}
</Button> </Button>
</div>
)} )}
</div> </div>
{user.name && !user.live && <div className="NoNetwork"></div>} {user.name && !user.live && <div className="NoNetwork"></div>}
@ -198,6 +233,26 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
</Box> </Box>
))} ))}
</List> </List>
{/* Bot Configuration Dialog */}
<Dialog open={botConfigDialogOpen} onClose={handleCloseBotConfig} maxWidth="md" fullWidth>
<DialogTitle>Configure Bot</DialogTitle>
<DialogContent>
{selectedBotForConfig && (
<BotConfig
botName={selectedBotForConfig.name || "unknown"}
lobbyId={lobbyId}
onConfigUpdate={(config) => {
console.log("Bot configuration updated:", config);
// Configuration updates are handled via WebSocket, so we don't need to do anything special here
}}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseBotConfig}>Close</Button>
</DialogActions>
</Dialog>
</Paper> </Paper>
); );
}; };

View File

@ -40,6 +40,7 @@ services:
- ./.env - ./.env
environment: environment:
- PRODUCTION=${PRODUCTION:-false} - PRODUCTION=${PRODUCTION:-false}
- PYTHONPATH=/:/server
restart: always restart: always
ports: ports:
- "8001:8000" - "8001:8000"

View File

@ -5,15 +5,9 @@ This module contains admin-only endpoints for managing users, sessions, and syst
Extracted from main.py to improve maintainability and separation of concerns. Extracted from main.py to improve maintainability and separation of concerns.
""" """
import sys from typing import TYPE_CHECKING, Optional
import os
from typing import TYPE_CHECKING
# Add the parent directory of server to the path to access shared # Add the parent directory of server to the path to access shared
current_dir = os.path.dirname(os.path.abspath(__file__))
server_dir = os.path.dirname(current_dir)
project_root = os.path.dirname(server_dir)
sys.path.insert(0, project_root)
from fastapi import APIRouter, Request, Response, Body from fastapi import APIRouter, Request, Response, Body
from shared.models import ( from shared.models import (
@ -44,8 +38,8 @@ class AdminAPI:
session_manager: "SessionManager", session_manager: "SessionManager",
lobby_manager: "LobbyManager", lobby_manager: "LobbyManager",
auth_manager: "AuthManager", auth_manager: "AuthManager",
admin_token: str = None, admin_token: Optional[str] = None,
public_url: str = "/" public_url: str = "/",
): ):
self.session_manager = session_manager self.session_manager = session_manager
self.lobby_manager = lobby_manager self.lobby_manager = lobby_manager
@ -65,7 +59,7 @@ class AdminAPI:
"""Register all admin routes""" """Register all admin routes"""
@self.router.get("/names", response_model=AdminNamesResponse) @self.router.get("/names", response_model=AdminNamesResponse)
def list_names(request: Request): def list_names(request: Request): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -73,7 +67,7 @@ class AdminAPI:
return AdminNamesResponse(name_passwords=name_passwords_models) return AdminNamesResponse(name_passwords=name_passwords_models)
@self.router.post("/set_password", response_model=AdminActionResponse) @self.router.post("/set_password", response_model=AdminActionResponse)
def set_password(request: Request, payload: AdminSetPassword = Body(...)): def set_password(request: Request, payload: AdminSetPassword = Body(...)): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -82,7 +76,7 @@ class AdminAPI:
return AdminActionResponse(status="ok", name=payload.name) return AdminActionResponse(status="ok", name=payload.name)
@self.router.post("/clear_password", response_model=AdminActionResponse) @self.router.post("/clear_password", response_model=AdminActionResponse)
def clear_password(request: Request, payload: AdminClearPassword = Body(...)): def clear_password(request: Request, payload: AdminClearPassword = Body(...)): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -92,7 +86,7 @@ class AdminAPI:
return AdminActionResponse(status="not_found", name=payload.name) return AdminActionResponse(status="not_found", name=payload.name)
@self.router.post("/cleanup_sessions", response_model=AdminActionResponse) @self.router.post("/cleanup_sessions", response_model=AdminActionResponse)
def cleanup_sessions(request: Request): def cleanup_sessions(request: Request): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -107,7 +101,7 @@ class AdminAPI:
return AdminActionResponse(status="error", name=f"Error: {str(e)}") return AdminActionResponse(status="error", name=f"Error: {str(e)}")
@self.router.get("/session_metrics", response_model=AdminMetricsResponse) @self.router.get("/session_metrics", response_model=AdminMetricsResponse)
def session_metrics(request: Request): def session_metrics(request: Request): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -118,7 +112,7 @@ class AdminAPI:
return Response(status_code=500) return Response(status_code=500)
@self.router.get("/validate_sessions", response_model=AdminValidationResponse) @self.router.get("/validate_sessions", response_model=AdminValidationResponse)
def validate_sessions(request: Request): def validate_sessions(request: Request): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)
@ -137,7 +131,7 @@ class AdminAPI:
return AdminValidationResponse(status="error", error=str(e)) return AdminValidationResponse(status="error", error=str(e))
@self.router.post("/cleanup_lobbies", response_model=AdminActionResponse) @self.router.post("/cleanup_lobbies", response_model=AdminActionResponse)
def cleanup_lobbies(request: Request): def cleanup_lobbies(request: Request): # type: ignore
if not self._require_admin(request): if not self._require_admin(request):
return Response(status_code=403) return Response(status_code=403)

View File

@ -5,9 +5,10 @@ This module provides REST API endpoints for managing bot configurations
including schema discovery, configuration CRUD operations, and real-time updates. including schema discovery, configuration CRUD operations, and real-time updates.
""" """
from typing import Dict, List, Optional, Any from typing import Dict, Any
from fastapi import APIRouter, HTTPException, BackgroundTasks, WebSocket from fastapi import APIRouter, HTTPException, BackgroundTasks, WebSocket
from core.bot_manager import BotManager
from logger import logger from logger import logger
from core.bot_config_manager import BotConfigManager from core.bot_config_manager import BotConfigManager
@ -28,53 +29,36 @@ try:
) )
except ImportError: except ImportError:
try: try:
# Try direct import (when PYTHONPATH is set)
from shared.models import ( from shared.models import (
BotConfigSchema, BotConfigSchema,
BotLobbyConfig, BotLobbyConfig,
BotConfigUpdateRequest, BotConfigUpdateRequest,
BotConfigUpdateResponse, BotConfigUpdateResponse,
BotConfigListResponse BotConfigListResponse,
) )
except ImportError: except ImportError:
# Create dummy models for standalone testing # Log a warning for debugging (optional)
from pydantic import BaseModel import warnings
class BotConfigSchema(BaseModel): warnings.warn(
bot_name: str "Relative import failed, ensure PYTHONPATH includes project root or run as package"
version: str = "1.0" )
parameters: List[Dict[str, Any]] # Rely on environment setup or raise a clear error
raise ImportError(
class BotLobbyConfig(BaseModel): "Cannot import shared.models. Ensure the project is run as a package or PYTHONPATH is set."
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: def create_bot_config_router(
config_manager: BotConfigManager, bot_manager: BotManager
) -> APIRouter:
"""Create FastAPI router for bot configuration endpoints""" """Create FastAPI router for bot configuration endpoints"""
router = APIRouter(prefix="/api/bots/config", tags=["Bot Configuration"]) router = APIRouter(prefix="/api/bots/config", tags=["Bot Configuration"])
@router.get("/schema/{bot_name}") @router.get("/schema/{bot_name}")
async def get_bot_config_schema(bot_name: str) -> BotConfigSchema: async def get_bot_config_schema(bot_name: str) -> BotConfigSchema: # type: ignore
"""Get configuration schema for a specific bot""" """Get configuration schema for a specific bot"""
try: try:
# Check if we have cached schema # Check if we have cached schema
@ -82,11 +66,13 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
if not schema: if not schema:
# Try to discover schema from bot provider # Try to discover schema from bot provider
providers = bot_manager.get_providers() providers_response = bot_manager.list_providers()
for provider_id, provider in providers.items(): for provider in providers_response.providers:
try: try:
# Check if this provider has the bot # Check if this provider has the bot
provider_bots = await bot_manager.get_provider_bots(provider_id) provider_bots = await bot_manager.get_provider_bots(
provider.provider_id
)
bot_names = [bot.name for bot in provider_bots.bots] bot_names = [bot.name for bot in provider_bots.bots]
if bot_name in bot_names: if bot_name in bot_names:
@ -96,13 +82,40 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
if schema: if schema:
break break
except Exception as e: except Exception as e:
logger.warning(f"Failed to check provider {provider_id} for bot {bot_name}: {e}") logger.warning(
f"Failed to check provider {provider.provider_id} for bot {bot_name}: {e}"
)
continue
else:
# We have a cached schema, but check if it might be stale
# Try to refresh it automatically if it's older than 1 hour
providers_response = bot_manager.list_providers()
for provider in providers_response.providers:
try:
provider_bots = await bot_manager.get_provider_bots(
provider.provider_id
)
bot_names = [bot.name for bot in provider_bots.bots]
if bot_name in bot_names:
# This will only refresh if the cached schema is older than 1 hour
fresh_schema = (
await config_manager.discover_bot_config_schema(
bot_name, provider.base_url, force_refresh=False
)
)
if fresh_schema:
schema = fresh_schema
break
except Exception as e:
logger.debug(f"Failed to refresh schema for {bot_name}: {e}")
# Continue with cached schema if refresh fails
continue continue
if not schema: if not schema:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"No configuration schema found for bot '{bot_name}'" detail=f"No configuration schema found for bot '{bot_name}'",
) )
return schema return schema
@ -132,7 +145,7 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
if not config: if not config:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"No configuration found for bot '{bot_name}' in lobby '{lobby_id}'" detail=f"No configuration found for bot '{bot_name}' in lobby '{lobby_id}'",
) )
return config return config
@ -140,14 +153,16 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to get config for bot {bot_name} in lobby {lobby_id}: {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") raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/update") @router.post("/update")
async def update_bot_config( async def update_bot_config(
request: BotConfigUpdateRequest, request: BotConfigUpdateRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
session_id: str = "unknown" # TODO: Get from auth/session context session_id: str = "unknown", # TODO: Get from auth/session context
) -> BotConfigUpdateResponse: ) -> BotConfigUpdateResponse:
"""Update bot configuration for a lobby""" """Update bot configuration for a lobby"""
try: try:
@ -155,14 +170,16 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
provider_id = None provider_id = None
provider_url = None provider_url = None
providers = bot_manager.get_providers() providers_response = bot_manager.list_providers()
for pid, provider in providers.items(): for provider in providers_response.providers:
try: try:
provider_bots = await bot_manager.get_provider_bots(pid) provider_bots = await bot_manager.get_provider_bots(
provider.provider_id
)
bot_names = [bot.name for bot in provider_bots.bots] bot_names = [bot.name for bot in provider_bots.bots]
if request.bot_name in bot_names: if request.bot_name in bot_names:
provider_id = pid provider_id = provider.provider_id
provider_url = provider.base_url provider_url = provider.base_url
break break
except Exception: except Exception:
@ -175,6 +192,12 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
) )
# Update configuration # Update configuration
if not provider_id or not provider_url:
raise HTTPException(
status_code=404,
detail=f"Bot {request.bot_name} not found in any registered provider",
)
config = config_manager.set_bot_config( config = config_manager.set_bot_config(
lobby_id=request.lobby_id, lobby_id=request.lobby_id,
bot_name=request.bot_name, bot_name=request.bot_name,
@ -260,16 +283,20 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
try: try:
async def refresh_task(): async def refresh_task():
refreshed = 0 refreshed = 0
providers = bot_manager.get_providers() providers_response = bot_manager.list_providers()
for provider_id, provider in providers.items(): for provider in providers_response.providers:
try: try:
provider_bots = await bot_manager.get_provider_bots(provider_id) provider_bots = await bot_manager.get_provider_bots(
provider.provider_id
)
for bot in provider_bots.bots: for bot in provider_bots.bots:
try: try:
schema = await config_manager.discover_bot_config_schema( schema = (
bot.name, provider.base_url await config_manager.discover_bot_config_schema(
bot.name, provider.base_url, force_refresh=True
)
) )
if schema: if schema:
refreshed += 1 refreshed += 1
@ -277,7 +304,9 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
logger.warning(f"Failed to refresh schema for {bot.name}: {e}") logger.warning(f"Failed to refresh schema for {bot.name}: {e}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to refresh schemas from provider {provider_id}: {e}") logger.warning(
f"Failed to refresh schemas from provider {provider.provider_id}: {e}"
)
logger.info(f"Refreshed {refreshed} bot configuration schemas") logger.info(f"Refreshed {refreshed} bot configuration schemas")
@ -292,6 +321,70 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
logger.error(f"Failed to start schema refresh: {e}") logger.error(f"Failed to start schema refresh: {e}")
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/schema/{bot_name}/refresh")
async def refresh_bot_schema(bot_name: str) -> Dict[str, Any]:
"""Refresh configuration schema for a specific bot"""
try:
# Find the provider for this bot
providers_response = bot_manager.list_providers()
for provider in providers_response.providers:
try:
provider_bots = await bot_manager.get_provider_bots(
provider.provider_id
)
for bot in provider_bots.bots:
if bot.name == bot_name:
schema = await config_manager.refresh_bot_schema(
bot_name, provider.base_url
)
if schema:
return {
"success": True,
"message": f"Schema refreshed for bot {bot_name}",
"schema": schema.model_dump(),
}
else:
raise HTTPException(
status_code=404,
detail=f"Bot {bot_name} does not support configuration",
)
except Exception as e:
logger.warning(
f"Failed to check provider {provider.provider_id}: {e}"
)
continue
raise HTTPException(status_code=404, detail=f"Bot {bot_name} not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to refresh schema for bot {bot_name}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/schema/{bot_name}/cache")
async def clear_bot_schema_cache(bot_name: str) -> Dict[str, Any]:
"""Clear cached schema for a specific bot"""
try:
success = config_manager.clear_bot_schema_cache(bot_name)
if success:
return {
"success": True,
"message": f"Schema cache cleared for bot {bot_name}",
}
else:
raise HTTPException(
status_code=404, detail=f"No cached schema found for bot {bot_name}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to clear schema cache for bot {bot_name}: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
return router return router

View File

@ -8,7 +8,6 @@ Extracted from main.py to improve maintainability and separation of concerns.
import hashlib import hashlib
import binascii import binascii
import secrets import secrets
import os
import threading import threading
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
@ -18,17 +17,19 @@ try:
from ...shared.models import NamePasswordRecord from ...shared.models import NamePasswordRecord
except ImportError: except ImportError:
try: try:
# Try absolute import (when running directly) # Try direct import (when PYTHONPATH is set)
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from shared.models import NamePasswordRecord from shared.models import NamePasswordRecord
except ImportError: except ImportError:
# Fallback: create minimal model for testing # Log a warning for debugging (optional)
from pydantic import BaseModel import warnings
class NamePasswordRecord(BaseModel):
name: str warnings.warn(
password: str "Relative import failed, ensure PYTHONPATH includes project root or run as package"
)
# Rely on environment setup or raise a clear error
raise ImportError(
"Cannot import shared.models. Ensure the project is run as a package or PYTHONPATH is set."
)
from logger import logger from logger import logger
@ -163,14 +164,10 @@ class AuthManager:
def validate_integrity(self) -> list[str]: def validate_integrity(self) -> list[str]:
"""Validate auth data integrity and return list of issues""" """Validate auth data integrity and return list of issues"""
issues = [] issues: list[str] = []
with self.lock: with self.lock:
for name, record in self.name_passwords.items(): for name, record in self.name_passwords.items():
if not isinstance(record, dict):
issues.append(f"Name '{name}' has invalid record type: {type(record)}")
continue
if "salt" not in record or "hash" not in record: if "salt" not in record or "hash" not in record:
issues.append(f"Name '{name}' missing salt or hash") issues.append(f"Name '{name}' missing salt or hash")
continue continue

View File

@ -149,9 +149,24 @@ class BotConfigManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to save bot schema {bot_name}: {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]: async def discover_bot_config_schema(
self, bot_name: str, provider_url: str, force_refresh: bool = False
) -> Optional[BotConfigSchema]:
"""Discover configuration schema from bot provider""" """Discover configuration schema from bot provider"""
try: try:
# Check if we have a cached schema and it's not forced refresh
if not force_refresh and bot_name in self.schema_cache:
cached_schema = self.schema_cache[bot_name]
# Check if schema is less than 1 hour old
schema_file = self._get_schema_file(bot_name)
if schema_file.exists():
file_age = time.time() - schema_file.stat().st_mtime
if file_age < 3600: # 1 hour
logger.debug(
f"Using cached schema for bot {bot_name} (age: {file_age:.0f}s)"
)
return cached_schema
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Try to get configuration schema from bot provider # Try to get configuration schema from bot provider
response = await client.get( response = await client.get(
@ -163,17 +178,35 @@ class BotConfigManager:
schema_data = response.json() schema_data = response.json()
schema = BotConfigSchema(**schema_data) schema = BotConfigSchema(**schema_data)
# Check if schema has actually changed
if bot_name in self.schema_cache:
old_schema = self.schema_cache[bot_name]
if old_schema.model_dump() == schema.model_dump():
logger.debug(
f"Schema for bot {bot_name} unchanged, updating timestamp only"
)
else:
logger.info(f"Schema for bot {bot_name} has been updated")
# Cache the schema # Cache the schema
self.schema_cache[bot_name] = schema self.schema_cache[bot_name] = schema
self._save_bot_schema(bot_name) self._save_bot_schema(bot_name)
logger.info(f"Discovered config schema for bot {bot_name}") logger.info(
f"Discovered/refreshed config schema for bot {bot_name}"
)
return schema return schema
else: else:
logger.warning(f"Bot {bot_name} does not support configuration (HTTP {response.status_code})") logger.warning(f"Bot {bot_name} does not support configuration (HTTP {response.status_code})")
except Exception as e: except Exception as e:
logger.warning(f"Failed to discover config schema for bot {bot_name}: {e}") logger.warning(f"Failed to discover config schema for bot {bot_name}: {e}")
# Return cached schema if available, even if refresh failed
if bot_name in self.schema_cache:
logger.info(
f"Returning cached schema for bot {bot_name} after refresh failure"
)
return self.schema_cache[bot_name]
return None return None
@ -181,6 +214,26 @@ class BotConfigManager:
"""Get cached configuration schema for a bot""" """Get cached configuration schema for a bot"""
return self.schema_cache.get(bot_name) return self.schema_cache.get(bot_name)
async def refresh_bot_schema(
self, bot_name: str, provider_url: str
) -> Optional[BotConfigSchema]:
"""Force refresh of bot schema from provider"""
return await self.discover_bot_config_schema(
bot_name, provider_url, force_refresh=True
)
def clear_bot_schema_cache(self, bot_name: str) -> bool:
"""Clear cached schema for a specific bot"""
if bot_name in self.schema_cache:
del self.schema_cache[bot_name]
# Also remove the cached file
schema_file = self._get_schema_file(bot_name)
if schema_file.exists():
schema_file.unlink()
logger.info(f"Cleared schema cache for bot {bot_name}")
return True
return False
def get_lobby_bot_config(self, lobby_id: str, bot_name: str) -> Optional[BotLobbyConfig]: def get_lobby_bot_config(self, lobby_id: str, bot_name: str) -> Optional[BotLobbyConfig]:
"""Get bot configuration for a specific lobby""" """Get bot configuration for a specific lobby"""
if lobby_id in self.config_cache and bot_name in self.config_cache[lobby_id]: if lobby_id in self.config_cache and bot_name in self.config_cache[lobby_id]:

View File

@ -28,6 +28,7 @@ try:
) )
except ImportError: except ImportError:
try: try:
# Try direct import (when PYTHONPATH is set)
from shared.models import ( from shared.models import (
BotProviderModel, BotProviderModel,
BotProviderRegisterRequest, BotProviderRegisterRequest,
@ -44,70 +45,16 @@ except ImportError:
BotJoinPayload, BotJoinPayload,
) )
except ImportError: except ImportError:
# Create dummy models for standalone testing # Log a warning for debugging (optional)
from pydantic import BaseModel import warnings
class BotProviderModel(BaseModel): warnings.warn(
provider_id: str "Relative import failed, ensure PYTHONPATH includes project root or run as package"
base_url: str )
name: str # Rely on environment setup or raise a clear error
description: str raise ImportError(
provider_key: str "Cannot import shared.models. Ensure the project is run as a package or PYTHONPATH is set."
registered_at: float )
last_seen: float
class BotProviderRegisterRequest(BaseModel):
base_url: str
name: str
description: str
provider_key: str
class BotProviderRegisterResponse(BaseModel):
provider_id: str
class BotProviderListResponse(BaseModel):
providers: List[BotProviderModel]
class BotInfoModel(BaseModel):
name: str
description: str
has_media: bool = False
class BotListResponse(BaseModel):
bots: List[BotInfoModel]
providers: Dict[str, str]
class BotJoinLobbyRequest(BaseModel):
lobby_id: str
provider_id: Optional[str] = None
nick: Optional[str] = None
class BotJoinLobbyResponse(BaseModel):
status: str
bot_name: str
run_id: str
provider_id: str
class BotLeaveLobbyRequest(BaseModel):
session_id: str
class BotLeaveLobbyResponse(BaseModel):
status: str
session_id: str
run_id: Optional[str] = None
class BotProviderBotsResponse(BaseModel):
bots: List[BotInfoModel]
class BotProviderJoinResponse(BaseModel):
run_id: str
class BotJoinPayload(BaseModel):
lobby_id: str
session_id: str
nick: str
server_url: str
insecure: bool = True
class BotProviderConfig: class BotProviderConfig:
@ -235,6 +182,26 @@ class BotManager:
return BotListResponse(bots=bots, providers=providers) return BotListResponse(bots=bots, providers=providers)
async def get_provider_bots(self, provider_id: str) -> BotProviderBotsResponse:
"""Get bots from a specific provider"""
provider = self.get_provider(provider_id)
if not provider:
raise ValueError(f"Provider {provider_id} not found")
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{provider.base_url}/bots", timeout=5.0)
if response.status_code == 200:
return BotProviderBotsResponse.model_validate(response.json())
else:
logger.warning(
f"Failed to fetch bots from provider {provider.name}: HTTP {response.status_code}"
)
return BotProviderBotsResponse(bots=[])
except Exception as e:
logger.error(f"Error fetching bots from provider {provider.name}: {e}")
return BotProviderBotsResponse(bots=[])
async def request_bot_join(self, bot_name: str, request: BotJoinLobbyRequest, session_manager, lobby_manager) -> BotJoinLobbyResponse: async def request_bot_join(self, bot_name: str, request: BotJoinLobbyRequest, session_manager, lobby_manager) -> BotJoinLobbyResponse:
"""Request a bot to join a specific lobby""" """Request a bot to join a specific lobby"""

View File

@ -251,15 +251,17 @@ class SessionManager:
if not session_id: if not session_id:
session_id = secrets.token_hex(16) session_id = secrets.token_hex(16)
# Check if session already exists with self.lock:
# Check if session already exists (now inside the lock for atomicity)
existing_session = self.get_session(session_id) existing_session = self.get_session(session_id)
if existing_session: if existing_session:
logger.debug(f"Session {session_id[:8]} already exists, returning existing session") logger.debug(
f"Session {session_id[:8]} already exists, returning existing session"
)
return existing_session return existing_session
# Create new session
session = Session(session_id, is_bot=is_bot, has_media=has_media) session = Session(session_id, is_bot=is_bot, has_media=has_media)
with self.lock:
self._instances.append(session) self._instances.append(session)
self.save() self.save()
@ -276,12 +278,11 @@ class SessionManager:
def get_session(self, session_id: str) -> Optional[Session]: def get_session(self, session_id: str) -> Optional[Session]:
"""Get session by ID""" """Get session by ID"""
with self.lock:
if not self._loaded: if not self._loaded:
self.load() self.load()
logger.info(f"Loaded {len(self._instances)} sessions from disk...") logger.info(f"Loaded {len(self._instances)} sessions from disk...")
self._loaded = True
with self.lock:
for s in self._instances: for s in self._instances:
if s.id == session_id: if s.id == session_id:
return s return s
@ -402,6 +403,19 @@ class SessionManager:
logger.info(f"Expiring session {s_saved.id[:8]}:{name} during load") logger.info(f"Expiring session {s_saved.id[:8]}:{name} during load")
continue continue
# Check if session already exists in _instances (deduplication)
existing_session = None
for existing in self._instances:
if existing.id == s_saved.id:
existing_session = existing
break
if existing_session:
logger.debug(
f"Session {s_saved.id[:8]} already loaded, skipping duplicate"
)
continue
session = Session( session = Session(
s_saved.id, s_saved.id,
is_bot=getattr(s_saved, "is_bot", False), is_bot=getattr(s_saved, "is_bot", False),
@ -426,6 +440,9 @@ class SessionManager:
logger.info(f"Expired {sessions_expired} old sessions during load") logger.info(f"Expired {sessions_expired} old sessions during load")
self.save() self.save()
# Mark as loaded to prevent duplicate loads
self._loaded = True
@staticmethod @staticmethod
def _should_remove_session_static( def _should_remove_session_static(
name: str, name: str,

View File

@ -17,6 +17,7 @@ fi
export VIRTUAL_ENV=/server/.venv export VIRTUAL_ENV=/server/.venv
export PATH="$VIRTUAL_ENV/bin:$PATH" export PATH="$VIRTUAL_ENV/bin:$PATH"
export PYTHONPATH="/:/server"
if [ -f "${SSL_CERTFILE}" ] && [ -f "${SSL_KEYFILE}" ]; then if [ -f "${SSL_CERTFILE}" ] && [ -f "${SSL_KEYFILE}" ]; then
echo "Starting server with SSL..." echo "Starting server with SSL..."

View File

@ -452,18 +452,9 @@ def get_config_schema() -> Dict[str, Any]:
} }
], ],
"categories": [ "categories": [
{ {"AI Settings": ["personality", "ai_provider", "streaming"]},
"name": "AI Settings", {"Behavior Settings": ["memory_enabled", "response_length", "creativity_level"]},
"parameters": ["personality", "ai_provider", "streaming"] {"Communication Style": ["response_style"]}
},
{
"name": "Behavior Settings",
"parameters": ["memory_enabled", "response_length", "creativity_level"]
},
{
"name": "Communication Style",
"parameters": ["response_style"]
}
] ]
} }