WebRTC now working

This commit is contained in:
James Ketr 2025-09-04 16:54:38 -07:00
parent 728293ea2e
commit 6b47704723
11 changed files with 508 additions and 7548 deletions

View File

@ -5,13 +5,17 @@ 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.
""" """
from typing import TYPE_CHECKING
from fastapi import APIRouter, Request, Response, Body
# Import shared models
import sys import sys
import os import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) from typing import TYPE_CHECKING
# 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 ( from shared.models import (
AdminNamesResponse, AdminNamesResponse,
AdminActionResponse, AdminActionResponse,
@ -21,13 +25,15 @@ from shared.models import (
AdminMetricsResponse, AdminMetricsResponse,
AdminMetricsConfig, AdminMetricsConfig,
) )
from logger import logger from logger import logger
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.session_manager import SessionManager from core.session_manager import SessionManager, SessionConfig
from ..core.lobby_manager import LobbyManager from core.lobby_manager import LobbyManager
from ..core.auth_manager import AuthManager from core.auth_manager import AuthManager
else:
# Import for runtime
from core.session_manager import SessionConfig
class AdminAPI: class AdminAPI:
@ -161,10 +167,8 @@ class AdminAPI:
cleanup_candidates = 0 cleanup_candidates = 0
old_anonymous = 0 old_anonymous = 0
old_displaced = 0 old_displaced = 0
for session in all_sessions: for session in all_sessions:
from ..core.session_manager import SessionConfig
# Anonymous sessions # Anonymous sessions
if (not session.ws and not session.name and if (not session.ws and not session.name and
current_time - session.created_at > SessionConfig.ANONYMOUS_SESSION_TIMEOUT): current_time - session.created_at > SessionConfig.ANONYMOUS_SESSION_TIMEOUT):

View File

@ -2,17 +2,6 @@
Session management for the AI Voice Bot server. Session management for the AI Voice Bot server.
This module handles session lifecycle, persistence, and cleanup operations. This module handles session lifecycle, persistence, and cleanup operations.
Extracted from m await lobby.add await lobby.removeSession(self)
# Publish event
await event_bus.publish(SessionLeftLobby(
session_id=self.id,
lobby_id=lobby.id,(self)
# Publish event
await event_bus.publish(SessionJoinedLobby(
session_id=self.id,
lobby_id=lobby.id,to improve maintainability and separation of concerns.
""" """
from __future__ import annotations from __future__ import annotations
@ -148,13 +137,93 @@ class Session:
self.displaced_at = time.time() self.displaced_at = time.time()
async def join_lobby(self, lobby): async def join_lobby(self, lobby):
"""Join a lobby and update peers""" """Join a lobby and establish WebRTC peer connections"""
with self.session_lock: with self.session_lock:
if lobby not in self.lobbies: if lobby not in self.lobbies:
self.lobbies.append(lobby) self.lobbies.append(lobby)
# Initialize lobby_peers for this lobby if not exists
if lobby.id not in self.lobby_peers:
self.lobby_peers[lobby.id] = []
# Add to lobby first
await lobby.addSession(self) await lobby.addSession(self)
# Get existing peer sessions in this lobby for WebRTC setup
peer_sessions = []
for session in lobby.sessions.values():
if (
session.id != self.id and session.ws
): # Don't include self and only connected sessions
peer_sessions.append(session)
# Establish WebRTC peer connections with existing sessions
for peer_session in peer_sessions:
# Only establish connections if at least one session has media
if self.has_media or peer_session.has_media:
logger.info(
f"{self.getName()} <-> {peer_session.getName()} - Establishing WebRTC peer connection"
)
# Add peer to our lobby_peers list
with self.session_lock:
if peer_session.id not in self.lobby_peers[lobby.id]:
self.lobby_peers[lobby.id].append(peer_session.id)
# Add this session to peer's lobby_peers list
with peer_session.session_lock:
if lobby.id not in peer_session.lobby_peers:
peer_session.lobby_peers[lobby.id] = []
if self.id not in peer_session.lobby_peers[lobby.id]:
peer_session.lobby_peers[lobby.id].append(self.id)
# Send addPeer to existing peer (they should not create offer)
logger.info(
f"{self.getName()} -> {peer_session.getName()}:addPeer({self.getName()}, {lobby.getName()}, should_create_offer=False, has_media={self.has_media})"
)
try:
await peer_session.ws.send_json(
{
"type": "addPeer",
"data": {
"peer_id": self.id,
"peer_name": self.name,
"has_media": self.has_media,
"should_create_offer": False,
},
}
)
except Exception as e:
logger.warning(
f"Failed to send addPeer to {peer_session.getName()}: {e}"
)
# Send addPeer to this session (they should create offer)
if self.ws:
logger.info(
f"{self.getName()} -> {self.getName()}:addPeer({peer_session.getName()}, {lobby.getName()}, should_create_offer=True, has_media={peer_session.has_media})"
)
try:
await self.ws.send_json(
{
"type": "addPeer",
"data": {
"peer_id": peer_session.id,
"peer_name": peer_session.name,
"has_media": peer_session.has_media,
"should_create_offer": True,
},
}
)
except Exception as e:
logger.warning(
f"Failed to send addPeer to {self.getName()}: {e}"
)
else:
logger.info(
f"{self.getName()} - Skipping WebRTC connection with {peer_session.getName()} (neither has media: self={self.has_media}, peer={peer_session.has_media})"
)
# Publish join event # Publish join event
await event_bus.publish(SessionJoinedLobby( await event_bus.publish(SessionJoinedLobby(
session_id=self.id, session_id=self.id,
@ -163,13 +232,72 @@ class Session:
)) ))
async def leave_lobby(self, lobby): async def leave_lobby(self, lobby):
"""Leave a lobby and clean up peers""" """Leave a lobby and clean up WebRTC peer connections"""
# Get peer sessions before removing from lobby
peer_sessions = []
if lobby.id in self.lobby_peers:
for peer_id in self.lobby_peers[lobby.id]:
peer_session = None
# Find peer session in lobby
for session in lobby.sessions.values():
if session.id == peer_id:
peer_session = session
break
if peer_session and peer_session.ws:
peer_sessions.append(peer_session)
# Send removePeer messages to all peers
for peer_session in peer_sessions:
logger.info(f"{peer_session.getName()} <- remove_peer({self.getName()})")
try:
await peer_session.ws.send_json(
{
"type": "removePeer",
"data": {"peer_name": self.name, "peer_id": self.id},
}
)
except Exception as e:
logger.warning(
f"Failed to send removePeer to {peer_session.getName()}: {e}"
)
# Remove from peer's lobby_peers
with peer_session.session_lock:
if (
lobby.id in peer_session.lobby_peers
and self.id in peer_session.lobby_peers[lobby.id]
):
peer_session.lobby_peers[lobby.id].remove(self.id)
# Send removePeer to this session
if self.ws:
logger.info(
f"{self.getName()} <- remove_peer({peer_session.getName()})"
)
try:
await self.ws.send_json(
{
"type": "removePeer",
"data": {
"peer_name": peer_session.name,
"peer_id": peer_session.id,
},
}
)
except Exception as e:
logger.warning(
f"Failed to send removePeer to {self.getName()}: {e}"
)
# Clean up our lobby_peers and lobbies
with self.session_lock: with self.session_lock:
if lobby in self.lobbies: if lobby in self.lobbies:
self.lobbies.remove(lobby) self.lobbies.remove(lobby)
if lobby.id in self.lobby_peers: if lobby.id in self.lobby_peers:
del self.lobby_peers[lobby.id] del self.lobby_peers[lobby.id]
# Remove from lobby
await lobby.removeSession(self) await lobby.removeSession(self)
# Publish leave event # Publish leave event

File diff suppressed because it is too large Load Diff

View File

@ -1,293 +0,0 @@
"""
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)

File diff suppressed because it is too large Load Diff

View File

@ -1,213 +0,0 @@
"""
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
from fastapi.staticfiles import StaticFiles
# 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)
# Serve static files if available (for client)
try:
app.mount(public_url + "static", StaticFiles(directory="static"), name="static")
logger.info("Static files mounted at /static")
except Exception:
logger.info("No static directory found, skipping static file serving")
# 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,
},
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ from typing import Dict, Any, TYPE_CHECKING
from fastapi import WebSocket from fastapi import WebSocket
from logger import logger from logger import logger
from .webrtc_signaling import WebRTCSignalingHandlers
if TYPE_CHECKING: if TYPE_CHECKING:
from ..core.session_manager import Session from ..core.session_manager import Session
@ -255,6 +256,38 @@ class SendChatMessageHandler(MessageHandler):
await lobby.broadcast_chat_message(chat_message) await lobby.broadcast_chat_message(chat_message)
class RelayICECandidateHandler(MessageHandler):
"""Handler for relayICECandidate messages - WebRTC signaling"""
async def handle(
self,
session: "Session",
lobby: "Lobby",
data: Dict[str, Any],
websocket: WebSocket,
managers: Dict[str, Any],
) -> None:
await WebRTCSignalingHandlers.handle_relay_ice_candidate(
websocket, session, lobby, data
)
class RelaySessionDescriptionHandler(MessageHandler):
"""Handler for relaySessionDescription messages - WebRTC signaling"""
async def handle(
self,
session: "Session",
lobby: "Lobby",
data: Dict[str, Any],
websocket: WebSocket,
managers: Dict[str, Any],
) -> None:
await WebRTCSignalingHandlers.handle_relay_session_description(
websocket, session, lobby, data
)
class MessageRouter: class MessageRouter:
"""Routes WebSocket messages to appropriate handlers""" """Routes WebSocket messages to appropriate handlers"""
@ -270,6 +303,10 @@ class MessageRouter:
self.register("list_users", ListUsersHandler()) self.register("list_users", ListUsersHandler())
self.register("get_chat_messages", GetChatMessagesHandler()) self.register("get_chat_messages", GetChatMessagesHandler())
self.register("send_chat_message", SendChatMessageHandler()) self.register("send_chat_message", SendChatMessageHandler())
# WebRTC signaling handlers
self.register("relayICECandidate", RelayICECandidateHandler())
self.register("relaySessionDescription", RelaySessionDescriptionHandler())
def register(self, message_type: str, handler: MessageHandler): def register(self, message_type: str, handler: MessageHandler):
"""Register a handler for a message type""" """Register a handler for a message type"""

View File

@ -0,0 +1,199 @@
"""
WebRTC Signaling Handlers
This module contains WebRTC signaling message handlers for peer-to-peer communication.
Handles ICE candidate relay and session description exchange between peers.
"""
from typing import Any, Dict, TYPE_CHECKING
from fastapi import WebSocket
from logger import logger
if TYPE_CHECKING:
from core.session_manager import Session
from core.lobby_manager import Lobby
class WebRTCSignalingHandlers:
"""WebRTC signaling message handlers for peer-to-peer communication."""
@staticmethod
async def handle_relay_ice_candidate(
websocket: WebSocket,
session: "Session",
lobby: "Lobby",
data: Dict[str, Any]
) -> None:
"""
Handle ICE candidate relay between peers.
Args:
websocket: The WebSocket connection
session: The sender session
lobby: The lobby context
data: Message data containing peer_id and candidate
"""
logger.info(f"{session.getName()} <- relayICECandidate")
if not data:
logger.error(f"{session.getName()} - relayICECandidate missing data")
await websocket.send_json({
"type": "error",
"data": {"error": "relayICECandidate missing data"}
})
return
# Check if session is properly joined to lobby with RTC peers
with session.session_lock:
if (lobby.id not in session.lobby_peers or
session.id not in lobby.sessions):
logger.error(
f"{session.short}:{session.name} <- relayICECandidate - "
f"Not an RTC peer ({session.id})"
)
await websocket.send_json({
"type": "error",
"data": {"error": "Not joined to lobby"}
})
return
session_peers = session.lobby_peers[lobby.id]
# Validate peer_id
peer_id = data.get("peer_id")
if peer_id not in session_peers:
logger.error(
f"{session.getName()} <- relayICECandidate - "
f"Not an RTC peer({peer_id}) in {session_peers}"
)
await websocket.send_json({
"type": "error",
"data": {"error": f"Target peer {peer_id} not found"}
})
return
# Get candidate data
candidate = data.get("candidate")
# Prepare message for target peer
message: Dict[str, Any] = {
"type": "iceCandidate",
"data": {
"peer_id": session.id,
"peer_name": session.name,
"candidate": candidate,
},
}
# Find target peer session and relay the message
peer_session = lobby.getSession(peer_id)
if not peer_session or not peer_session.ws:
logger.warning(
f"{session.getName()} - Live peer session {peer_id} "
f"not found in lobby {lobby.getName()}."
)
return
logger.info(
f"{session.getName()} -> iceCandidate({peer_session.getName()})"
)
try:
await peer_session.ws.send_json(message)
except Exception as e:
logger.warning(f"Failed to relay ICE candidate: {e}")
@staticmethod
async def handle_relay_session_description(
websocket: WebSocket,
session: "Session",
lobby: "Lobby",
data: Dict[str, Any]
) -> None:
"""
Handle session description relay between peers.
Args:
websocket: The WebSocket connection
session: The sender session
lobby: The lobby context
data: Message data containing peer_id and session_description
"""
logger.info(f"{session.getName()} <- relaySessionDescription")
if not data:
logger.error(f"{session.getName()} - relaySessionDescription missing data")
await websocket.send_json({
"type": "error",
"data": {"error": "relaySessionDescription missing data"}
})
return
# Check if session is properly joined to lobby with RTC peers
with session.session_lock:
if (lobby.id not in session.lobby_peers or
session.id not in lobby.sessions):
logger.error(
f"{session.short}:{session.name} <- relaySessionDescription - "
f"Not an RTC peer ({session.id})"
)
await websocket.send_json({
"type": "error",
"data": {"error": "Not joined to lobby"}
})
return
lobby_peers = session.lobby_peers[lobby.id]
# Validate peer_id
peer_id = data.get("peer_id")
if not peer_id:
logger.error(f"{session.getName()} - relaySessionDescription missing peer_id")
await websocket.send_json({
"type": "error",
"data": {"error": "relaySessionDescription missing peer_id"}
})
return
if peer_id not in lobby_peers:
logger.error(
f"{session.getName()} <- relaySessionDescription - "
f"Not an RTC peer({peer_id}) in {lobby_peers}"
)
await websocket.send_json({
"type": "error",
"data": {"error": f"Target peer {peer_id} not found"}
})
return
# Find target peer session
peer_session = lobby.getSession(peer_id)
if not peer_session or not peer_session.ws:
logger.warning(
f"{session.getName()} - Live peer session {peer_id} "
f"not found in lobby {lobby.getName()}."
)
return
# Get session description data
session_description = data.get("session_description")
# Prepare message for target peer
message: Dict[str, Any] = {
"type": "sessionDescription",
"data": {
"peer_id": session.id,
"peer_name": session.name,
"session_description": session_description,
},
}
logger.info(
f"{session.getName()} -> sessionDescription({peer_session.getName()})"
)
try:
await peer_session.ws.send_json(message)
except Exception as e:
logger.warning(f"Failed to relay session description: {e}")

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Test WebRTC signaling handlers registration
"""
import asyncio
import json
import websockets
import sys
async def test_webrtc_handlers():
"""Test that WebRTC signaling handlers are properly registered"""
try:
# Connect to the WebSocket endpoint
uri = "ws://localhost:8000/ai-voicebot/ws"
async with websockets.connect(uri) as websocket:
print("Connected to WebSocket")
# Send a set_name message first
await websocket.send(json.dumps({
"type": "set_name",
"data": {"name": "test_user"}
}))
response = await websocket.recv()
print(f"Set name response: {response}")
# Test relayICECandidate handler
test_message = {
"type": "relayICECandidate",
"data": {
"peer_id": "nonexistent_peer",
"candidate": {"candidate": "test"}
}
}
await websocket.send(json.dumps(test_message))
print("Sent relayICECandidate message")
# Expect an error response since we're not in a lobby
response = await websocket.recv()
print(f"ICE candidate response: {response}")
# Test relaySessionDescription handler
test_message = {
"type": "relaySessionDescription",
"data": {
"peer_id": "nonexistent_peer",
"session_description": {"type": "offer"}
}
}
await websocket.send(json.dumps(test_message))
print("Sent relaySessionDescription message")
# Expect an error response since we're not in a lobby
response = await websocket.recv()
print(f"Session description response: {response}")
print("WebRTC signaling handlers are working!")
except Exception as e:
print(f"Error testing WebRTC handlers: {e}")
return False
return True
if __name__ == "__main__":
success = asyncio.run(test_webrtc_handlers())
sys.exit(0 if success else 1)

View File

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Test script to verify WebRTC signaling handlers are registered
"""
import sys
# Add the server directory to Python path
sys.path.insert(0, '/home/jketreno/docker/ai-voicebot/server')
from websocket.message_handlers import MessageRouter
def test_webrtc_handlers():
"""Test that WebRTC signaling handlers are registered"""
router = MessageRouter()
supported_types = router.get_supported_types()
print("Supported message types:")
for msg_type in sorted(supported_types):
print(f" - {msg_type}")
# Check for WebRTC handlers
webrtc_handlers = [
"relayICECandidate",
"relaySessionDescription"
]
print("\nWebRTC signaling handlers:")
for handler in webrtc_handlers:
if handler in supported_types:
print(f"{handler} - REGISTERED")
else:
print(f"{handler} - MISSING")
return False
print("\n✅ All WebRTC signaling handlers are properly registered!")
return True
if __name__ == "__main__":
success = test_webrtc_handlers()
sys.exit(0 if success else 1)