300 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
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, Path, Request
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from starlette.websockets import WebSocketDisconnect
# 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
public_url = os.getenv("PUBLIC_URL", "/")
if not public_url.endswith("/"):
public_url += "/"
ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", None)
# 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 routers
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 events"""
global session_manager, lobby_manager, auth_manager, websocket_manager
global admin_api, session_api, lobby_api
# Startup
logger.info("Starting AI Voice Bot server with modular architecture...")
# Initialize managers
session_manager = SessionManager("sessions.json")
lobby_manager = LobbyManager()
auth_manager = AuthManager("sessions.json")
# Load existing data
session_manager.load()
# Restore lobbies for existing sessions
# Note: This is a simplified version - full lobby restoration would be more complex
for session in session_manager.get_all_sessions():
for lobby_info in session.lobbies:
# Create lobby if it doesn't exist
lobby = lobby_manager.create_or_get_lobby(
name=lobby_info.name, private=lobby_info.private
)
# Add session to lobby (but don't trigger events during startup)
with lobby.lock:
lobby.sessions[session.id] = session
# Set up dependency injection for name protection
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,
auth_manager=auth_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...")
# Stop background tasks
if session_manager:
await session_manager.stop_background_tasks()
logger.info("Server shutdown complete")
# Create FastAPI app
app = FastAPI(
title="AI Voice Bot Server (Refactored)",
description="WebRTC voice chat server with modular architecture",
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 | None = Path(...),
session_id: str | None = Path(...),
):
"""WebSocket endpoint for lobby connections - now uses WebSocketConnectionManager"""
await websocket_manager.handle_connection(websocket, lobby_id, session_id)
# Health check for the new architecture
@app.get(f"{public_url}api/system/health")
def system_health():
"""System health check showing manager status"""
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",
},
"statistics": {
"sessions": session_manager.get_session_count() if session_manager else 0,
"lobbies": lobby_manager.get_lobby_count() if lobby_manager else 0,
"protected_names": auth_manager.get_protection_count()
if auth_manager
else 0,
},
}
# Serve static files or proxy to frontend development server
PRODUCTION = os.getenv("PRODUCTION", "false").lower() == "true"
client_build_path = os.path.join(os.path.dirname(__file__), "/client/build")
if PRODUCTION:
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.info(f"Proxying static files to http://client:3000 at {public_url}")
import ssl
import httpx
@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"{request.url.scheme}://client:3000/{public_url.strip('/')}/{path}"
if not path:
url = f"{request.url.scheme}://client:3000/{public_url.strip('/')}"
headers = dict(request.headers)
try:
# Accept self-signed certs in dev
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=True)
content = await proxy_resp.aread()
# Remove problematic headers for browser decoding
filtered_headers = {
k: v
for k, v in proxy_resp.headers.items()
if k.lower()
not in ["content-encoding", "transfer-encoding", "content-length"]
}
return Response(
content=content,
status_code=proxy_resp.status_code,
headers=filtered_headers,
)
except Exception as e:
logger.error(f"Proxy error for {url}: {e}")
return Response("Proxy error", status_code=502)
# WebSocket proxy for /ws (for React DevTools, etc.)
import websockets
@app.websocket("/ws")
async def websocket_proxy(websocket: WebSocket):
logger.info("REACT: WebSocket proxy connection established.")
# Get scheme from websocket.url (should be 'ws' or 'wss')
scheme = websocket.url.scheme if hasattr(websocket, "url") else "ws"
target_url = f"{scheme}://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():
while True:
msg = await websocket.receive_text()
await target_ws.send(msg)
async def server_to_client():
while True:
msg = await target_ws.recv()
if isinstance(msg, str):
await websocket.send_text(msg)
else:
await websocket.send_bytes(msg)
try:
await asyncio.gather(client_to_server(), server_to_client())
except (WebSocketDisconnect, websockets.ConnectionClosed):
logger.info("REACT: WebSocket proxy connection closed.")
except Exception as e:
logger.error(f"REACT: WebSocket proxy error: {e}")
await websocket.close()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)