ai-voicebot/server/main_clean.py

294 lines
10 KiB
Python

"""
Refactored main.py - Step 1 of Server Architecture Improvement
This is a refactored version of the original main.py that demonstrates the new
modular architecture with separated concerns:
- SessionManager: Handles session lifecycle and persistence
- LobbyManager: Handles lobby management and chat
- AuthManager: Handles authentication and name protection
- WebSocket message routing: Clean message handling
- Separated API modules: Admin, session, and lobby endpoints
This maintains backward compatibility while providing a foundation for
further improvements.
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, Path, Request, Response
from fastapi.staticfiles import StaticFiles
import httpx
import ssl
import websockets
# Import our new modular components
try:
from core.session_manager import SessionManager
from core.lobby_manager import LobbyManager
from core.auth_manager import AuthManager
from websocket.connection import WebSocketConnectionManager
from api.admin import AdminAPI
from api.sessions import SessionAPI
from api.lobbies import LobbyAPI
except ImportError:
# Handle relative imports when running as module
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from core.session_manager import SessionManager
from core.lobby_manager import LobbyManager
from core.auth_manager import AuthManager
from websocket.connection import WebSocketConnectionManager
from api.admin import AdminAPI
from api.sessions import SessionAPI
from api.lobbies import LobbyAPI
from logger import logger
# Configuration
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN")
public_url = os.getenv("PUBLIC_URL", "/")
if not public_url.endswith("/"):
public_url += "/"
# Global managers - these replace the global variables from original main.py
session_manager: SessionManager = None
lobby_manager: LobbyManager = None
auth_manager: AuthManager = None
websocket_manager: WebSocketConnectionManager = None
# API instances
admin_api: AdminAPI = None
session_api: SessionAPI = None
lobby_api: LobbyAPI = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown"""
global session_manager, lobby_manager, auth_manager, websocket_manager
global admin_api, session_api, lobby_api
logger.info("Starting AI Voice Bot server with modular architecture...")
# Initialize core managers
session_manager = SessionManager()
lobby_manager = LobbyManager(session_manager=session_manager)
auth_manager = AuthManager()
# Set up cross-manager dependencies
session_manager.set_lobby_manager(lobby_manager)
lobby_manager.set_name_protection_checker(auth_manager.is_name_protected)
# Initialize WebSocket manager
websocket_manager = WebSocketConnectionManager(
session_manager=session_manager,
lobby_manager=lobby_manager
)
# Initialize API routers
admin_api = AdminAPI(
session_manager=session_manager,
lobby_manager=lobby_manager,
auth_manager=auth_manager,
admin_token=ADMIN_TOKEN,
public_url=public_url
)
session_api = SessionAPI(
session_manager=session_manager,
public_url=public_url
)
lobby_api = LobbyAPI(
session_manager=session_manager,
lobby_manager=lobby_manager,
public_url=public_url
)
# Register API routes
app.include_router(admin_api.router)
app.include_router(session_api.router)
app.include_router(lobby_api.router)
# Start background tasks
await session_manager.start_background_tasks()
logger.info("AI Voice Bot server started successfully!")
logger.info(f"Server URL: {public_url}")
logger.info(f"Sessions loaded: {session_manager.get_session_count()}")
logger.info(f"Lobbies available: {lobby_manager.get_lobby_count()}")
logger.info(f"Protected names: {auth_manager.get_protection_count()}")
if ADMIN_TOKEN:
logger.info("Admin endpoints protected with token")
else:
logger.warning("Admin endpoints are unprotected")
yield
# Shutdown
logger.info("Shutting down AI Voice Bot server...")
if session_manager:
await session_manager.stop_background_tasks()
await session_manager.cleanup_all_sessions()
logger.info("Server shutdown complete")
# Create FastAPI app with the new architecture
app = FastAPI(
title="AI Voice Bot Server",
description="Modular AI Voice Bot Server with WebRTC support",
version="2.0.0",
lifespan=lifespan
)
logger.info(f"Starting server with public URL: {public_url}")
@app.websocket(f"{public_url}" + "ws/lobby/{{lobby_id}}/{{session_id}}")
async def lobby_websocket(
websocket: WebSocket,
lobby_id: str = Path(...),
session_id: str = Path(...)
):
"""WebSocket endpoint for lobby connections - now uses WebSocketConnectionManager"""
await websocket_manager.handle_connection(websocket, lobby_id, session_id)
# WebSocket proxy for React dev server (development mode)
PRODUCTION = os.getenv("PRODUCTION", "false").lower() == "true"
if not PRODUCTION:
@app.websocket("/ws")
async def websocket_proxy(websocket: WebSocket):
"""Proxy WebSocket connections to React dev server"""
logger.info("REACT: WebSocket proxy connection established.")
target_url = "wss://client:3000/ws"
await websocket.accept()
try:
# Accept self-signed certs in dev for WSS
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
async with websockets.connect(target_url, ssl=ssl_ctx) as target_ws:
async def client_to_server():
try:
while True:
data = await websocket.receive_text()
await target_ws.send(data)
except Exception as e:
logger.debug(f"Client to server error: {e}")
async def server_to_client():
try:
while True:
data = await target_ws.recv()
await websocket.send_text(data)
except Exception as e:
logger.debug(f"Server to client error: {e}")
# Run both directions concurrently
import asyncio
await asyncio.gather(
client_to_server(),
server_to_client(),
return_exceptions=True
)
except Exception as e:
logger.warning(f"WebSocket proxy error: {e}")
finally:
try:
await websocket.close()
except:
pass
# Serve static files or proxy to frontend development server
client_build_path = "/client/build"
if PRODUCTION:
# In production, serve static files from the client build directory
if os.path.exists(client_build_path):
logger.info(f"Serving static files from: {client_build_path} at {public_url}")
app.mount(
public_url, StaticFiles(directory=client_build_path, html=True), name="static"
)
else:
logger.warning(f"Client build directory not found: {client_build_path}")
else:
# In development, proxy to the React dev server
logger.info(f"Proxying static files to http://client:3000 at {public_url}")
@app.api_route(
f"{public_url}{{path:path}}",
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"],
)
async def proxy_static(request: Request, path: str):
# Do not proxy API or websocket paths
if path.startswith("api/") or path.startswith("ws/"):
return Response(status_code=404)
url = f"https://client:3000/{public_url.strip('/')}/{path}"
if not path:
url = f"https://client:3000/{public_url.strip('/')}"
# Prepare headers but remove problematic ones for proxying
headers = dict(request.headers)
# Remove host header to avoid conflicts
headers.pop("host", None)
# Remove accept-encoding to prevent compression issues
headers.pop("accept-encoding", None)
try:
# Use HTTP instead of HTTPS for internal container communication
async with httpx.AsyncClient(verify=False) as client:
proxy_req = client.build_request(
request.method, url, headers=headers, content=await request.body()
)
proxy_resp = await client.send(proxy_req, stream=False)
# Get response headers but filter out problematic encoding headers
response_headers = dict(proxy_resp.headers)
# Remove content-encoding and transfer-encoding to prevent conflicts
response_headers.pop("content-encoding", None)
response_headers.pop("transfer-encoding", None)
response_headers.pop("content-length", None) # Let FastAPI calculate this
return Response(
content=proxy_resp.content,
status_code=proxy_resp.status_code,
headers=response_headers,
media_type=proxy_resp.headers.get("content-type")
)
except Exception as e:
logger.warning(f"Proxy error for {path}: {e}")
return Response(status_code=404)
# Health check for the new architecture
@app.get(f"{public_url}api/system/health")
def system_health():
return {
"status": "ok",
"architecture": "modular",
"version": "2.0.0",
"managers": {
"session_manager": "active" if session_manager else "inactive",
"lobby_manager": "active" if lobby_manager else "inactive",
"auth_manager": "active" if auth_manager else "inactive",
"websocket_manager": "active" if websocket_manager else "inactive",
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)