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" }}>
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />}
{session && socketUrl && lobby && (
<UserList socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
)}
{session && socketUrl && lobby && (
<LobbyChat socketUrl={socketUrl} session={session} lobbyId={lobby.id} />
)}

View File

@ -15,8 +15,15 @@
border-bottom: 1px solid #dee2e6;
}
.bot-config-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.bot-config-header h3 {
margin: 0 0 8px 0;
margin: 0;
color: #212529;
font-size: 1.5rem;
}
@ -172,6 +179,30 @@
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-error,
.bot-config-unavailable {

View File

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

View File

@ -2,12 +2,19 @@ import React, { useState, useEffect, useCallback } from "react";
import Paper from "@mui/material/Paper";
import List from "@mui/material/List";
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 { MediaControl, MediaAgent, Peer } from "./MediaControl";
import Box from "@mui/material/Box";
import { Session } from "./GlobalContext";
import useWebSocket from "react-use-websocket";
import { ApiClient, BotLeaveLobbyRequest } from "./api-client";
import BotConfig from "./BotConfig";
type User = {
name: string;
@ -24,13 +31,18 @@ type User = {
type UserListProps = {
socketUrl: string;
session: Session;
lobbyId: string;
};
const UserList: React.FC<UserListProps> = (props: UserListProps) => {
const { socketUrl, session } = props;
const { socketUrl, session, lobbyId } = props;
const [users, setUsers] = useState<User[] | null>(null);
const [peers, setPeers] = useState<Record<string, Peer>>({});
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 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(
(A: any, B: any) => {
if (!session) {
@ -155,16 +178,28 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
)}
</div>
{user.is_bot && !user.local && (
<Button
size="small"
variant="outlined"
color="secondary"
onClick={() => handleBotLeave(user)}
disabled={leavingBots.has(user.session_id)}
style={{ fontSize: "0.7em", minWidth: "50px", height: "24px" }}
>
{leavingBots.has(user.session_id) ? "..." : "Leave"}
</Button>
<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
size="small"
variant="outlined"
color="secondary"
onClick={() => handleBotLeave(user)}
disabled={leavingBots.has(user.session_id)}
style={{ fontSize: "0.7em", minWidth: "50px", height: "24px" }}
>
{leavingBots.has(user.session_id) ? "..." : "Leave"}
</Button>
</div>
)}
</div>
{user.name && !user.live && <div className="NoNetwork"></div>}
@ -198,6 +233,26 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
</Box>
))}
</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>
);
};

View File

@ -40,6 +40,7 @@ services:
- ./.env
environment:
- PRODUCTION=${PRODUCTION:-false}
- PYTHONPATH=/:/server
restart: always
ports:
- "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.
"""
import sys
import os
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional
# 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 shared.models import (
@ -44,8 +38,8 @@ class AdminAPI:
session_manager: "SessionManager",
lobby_manager: "LobbyManager",
auth_manager: "AuthManager",
admin_token: str = None,
public_url: str = "/"
admin_token: Optional[str] = None,
public_url: str = "/",
):
self.session_manager = session_manager
self.lobby_manager = lobby_manager
@ -65,7 +59,7 @@ class AdminAPI:
"""Register all admin routes"""
@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):
return Response(status_code=403)
@ -73,7 +67,7 @@ class AdminAPI:
return AdminNamesResponse(name_passwords=name_passwords_models)
@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):
return Response(status_code=403)
@ -82,7 +76,7 @@ class AdminAPI:
return AdminActionResponse(status="ok", name=payload.name)
@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):
return Response(status_code=403)
@ -92,7 +86,7 @@ class AdminAPI:
return AdminActionResponse(status="not_found", name=payload.name)
@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):
return Response(status_code=403)
@ -107,7 +101,7 @@ class AdminAPI:
return AdminActionResponse(status="error", name=f"Error: {str(e)}")
@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):
return Response(status_code=403)
@ -118,7 +112,7 @@ class AdminAPI:
return Response(status_code=500)
@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):
return Response(status_code=403)
@ -137,7 +131,7 @@ class AdminAPI:
return AdminValidationResponse(status="error", error=str(e))
@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):
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.
"""
from typing import Dict, List, Optional, Any
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, BackgroundTasks, WebSocket
from core.bot_manager import BotManager
from logger import logger
from core.bot_config_manager import BotConfigManager
@ -28,67 +29,52 @@ try:
)
except ImportError:
try:
# Try direct import (when PYTHONPATH is set)
from shared.models import (
BotConfigSchema,
BotLobbyConfig,
BotConfigUpdateRequest,
BotConfigUpdateResponse,
BotConfigListResponse
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]
# Log a warning for debugging (optional)
import warnings
warnings.warn(
"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."
)
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"""
router = APIRouter(prefix="/api/bots/config", tags=["Bot Configuration"])
@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"""
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():
providers_response = bot_manager.list_providers()
for provider in providers_response.providers:
try:
# 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]
if bot_name in bot_names:
schema = await config_manager.discover_bot_config_schema(
bot_name, provider.base_url
@ -96,34 +82,61 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
if schema:
break
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
if not schema:
raise HTTPException(
status_code=404,
detail=f"No configuration schema found for bot '{bot_name}'"
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"""
@ -132,37 +145,41 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
if not config:
raise HTTPException(
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
except HTTPException:
raise
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")
@router.post("/update")
async def update_bot_config(
request: BotConfigUpdateRequest,
background_tasks: BackgroundTasks,
session_id: str = "unknown" # TODO: Get from auth/session context
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():
providers_response = bot_manager.list_providers()
for provider in providers_response.providers:
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]
if request.bot_name in bot_names:
provider_id = pid
provider_id = provider.provider_id
provider_url = provider.base_url
break
except Exception:
@ -175,6 +192,12 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
)
# 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(
lobby_id=request.lobby_id,
bot_name=request.bot_name,
@ -260,16 +283,20 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
try:
async def refresh_task():
refreshed = 0
providers = bot_manager.get_providers()
for provider_id, provider in providers.items():
providers_response = bot_manager.list_providers()
for provider in providers_response.providers:
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:
try:
schema = await config_manager.discover_bot_config_schema(
bot.name, provider.base_url
schema = (
await config_manager.discover_bot_config_schema(
bot.name, provider.base_url, force_refresh=True
)
)
if schema:
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}")
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")
@ -291,6 +320,70 @@ def create_bot_config_router(config_manager: BotConfigManager, bot_manager) -> A
except Exception as e:
logger.error(f"Failed to start schema refresh: {e}")
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

View File

@ -8,7 +8,6 @@ Extracted from main.py to improve maintainability and separation of concerns.
import hashlib
import binascii
import secrets
import os
import threading
from typing import Dict, Optional, Tuple
@ -18,17 +17,19 @@ try:
from ...shared.models import NamePasswordRecord
except ImportError:
try:
# Try absolute import (when running directly)
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
# Try direct import (when PYTHONPATH is set)
from shared.models import NamePasswordRecord
except ImportError:
# Fallback: create minimal model for testing
from pydantic import BaseModel
class NamePasswordRecord(BaseModel):
name: str
password: str
# Log a warning for debugging (optional)
import warnings
warnings.warn(
"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
@ -163,14 +164,10 @@ class AuthManager:
def validate_integrity(self) -> list[str]:
"""Validate auth data integrity and return list of issues"""
issues = []
issues: list[str] = []
with self.lock:
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:
issues.append(f"Name '{name}' missing salt or hash")
continue

View File

@ -148,10 +148,25 @@ class BotConfigManager:
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]:
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"""
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:
# Try to get configuration schema from bot provider
response = await client.get(
@ -162,24 +177,62 @@ class BotConfigManager:
if response.status_code == 200:
schema_data = response.json()
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
self.schema_cache[bot_name] = schema
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
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 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
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)
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]:
"""Get bot configuration for a specific lobby"""

View File

@ -28,6 +28,7 @@ try:
)
except ImportError:
try:
# Try direct import (when PYTHONPATH is set)
from shared.models import (
BotProviderModel,
BotProviderRegisterRequest,
@ -44,70 +45,16 @@ except ImportError:
BotJoinPayload,
)
except ImportError:
# Create dummy models for standalone testing
from pydantic import BaseModel
class BotProviderModel(BaseModel):
provider_id: str
base_url: str
name: str
description: str
provider_key: str
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
# Log a warning for debugging (optional)
import warnings
warnings.warn(
"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."
)
class BotProviderConfig:
@ -234,6 +181,26 @@ class BotManager:
continue
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:
"""Request a bot to join a specific lobby"""

View File

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

View File

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

View File

@ -452,18 +452,9 @@ def get_config_schema() -> Dict[str, Any]:
}
],
"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"]
}
{"AI Settings": ["personality", "ai_provider", "streaming"]},
{"Behavior Settings": ["memory_enabled", "response_length", "creativity_level"]},
{"Communication Style": ["response_style"]}
]
}