diff --git a/.dockerignore b/.dockerignore index 09567e3..2633f3b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,28 +6,21 @@ node_modules build dist -__pycache__ +**/__pycache__ +**/.venv *.pyc *.pyo *.pyd *.log *.swp *.swo -.DS_Store -.vscode -.idea -*.sublime-workspace -*.sublime-project .env .env.* -dev-keys *.pem *.key -coverage *.bak *.tmp *.local package-lock.json yarn.lock pnpm-lock.yaml -*docker-compose.override.yml diff --git a/client/src/BotManager.tsx b/client/src/BotManager.tsx index c062c92..5c6735c 100644 --- a/client/src/BotManager.tsx +++ b/client/src/BotManager.tsx @@ -172,7 +172,7 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { {Object.entries(bots).map(([botName, botInfo]) => { const providerId = providers[botName]; const providerName = getProviderName(providerId); - + return ( = ({ lobbyId, onBotAdded, sx }) => { {botInfo.description} - + } + secondaryTypographyProps={{ component: "div" }} /> ); @@ -220,6 +216,7 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { } + secondaryTypographyProps={{ component: "div" }} /> ))} @@ -239,7 +236,7 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { {error} )} - + Select Bot @@ -258,15 +255,8 @@ const BotManager: React.FC = ({ lobbyId, onBotAdded, sx }) => { }} onClick={() => setSelectedBot(botName)} > - - + + ))} diff --git a/docker-compose.yml b/docker-compose.yml index 83b9527..60d2ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,15 +64,14 @@ services: - PRODUCTION=${PRODUCTION:-false} - VOICEBOT_MODE=provider restart: unless-stopped - network_mode: host volumes: - ./cache:/root/.cache:rw - ./shared:/shared:ro - ./voicebot:/voicebot:rw - ./voicebot/.venv:/voicebot/.venv:rw # network_mode: host -# networks: -# - ai-voicebot-net + networks: + - ai-voicebot-net networks: diff --git a/server/main.py b/server/main.py index 9b60f1a..9dda2f6 100644 --- a/server/main.py +++ b/server/main.py @@ -1433,13 +1433,17 @@ async def request_bot_join_lobby( # Create a session for the bot bot_session_id = secrets.token_hex(16) + # Create the Session object for the bot + bot_session = Session(bot_session_id) + logger.info(f"Created session for bot: {bot_session.getName()}") + # Determine server URL for the bot to connect back to # Use the server's public URL or construct from request server_base_url = os.getenv("PUBLIC_SERVER_URL", "http://localhost:8000") if server_base_url.endswith("/"): server_base_url = server_base_url[:-1] - bot_nick = request.nick or f"{bot_name}-bot" + bot_nick = request.nick or f"{bot_name}-bot-{bot_session_id[:8]}" # Prepare the join request for the bot provider bot_join_payload = BotJoinPayload( @@ -1447,7 +1451,7 @@ async def request_bot_join_lobby( session_id=bot_session_id, nick=bot_nick, server_url=f"{server_base_url}{public_url}".rstrip("/"), - insecure=False, # Assume secure by default + insecure=True, # Accept self-signed certificates in development ) try: diff --git a/voicebot/bot_orchestrator.py b/voicebot/bot_orchestrator.py index 55cfb14..f321521 100644 --- a/voicebot/bot_orchestrator.py +++ b/voicebot/bot_orchestrator.py @@ -11,6 +11,8 @@ import importlib import pkgutil import sys import os +import time +from contextlib import asynccontextmanager from typing import Dict, Any # Add the parent directory to sys.path to allow absolute imports @@ -24,11 +26,47 @@ from voicebot.models import JoinRequest from voicebot.webrtc_signaling import WebRTCSignalingClient -app = FastAPI(title="voicebot-bot-orchestrator") +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info(f"🚀 Voicebot bot orchestrator started successfully at {time.strftime('%Y-%m-%d %H:%M:%S')}") + # Log the discovered bots + bots = discover_bots() + if bots: + logger.info(f"📋 Discovered {len(bots)} bots: {list(bots.keys())}") + else: + logger.info("âš ī¸ No bots discovered") + + # Check for remote server registration + remote_server_url = os.getenv('VOICEBOT_SERVER_URL') + if remote_server_url: + # Attempt to register with remote server + try: + host = os.getenv('HOST', '0.0.0.0') + port = os.getenv('PORT', '8788') + insecure = os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true' + + provider_id = await _perform_server_registration(remote_server_url, host, port, insecure) + logger.info(f"🎉 Successfully registered with remote server! Provider ID: {provider_id}") + except Exception as e: + logger.error(f"❌ Failed to register with remote server: {e}") + logger.warning("âš ī¸ Bot orchestrator will continue running without remote registration") + else: + logger.info("â„šī¸ No VOICEBOT_SERVER_URL provided - running in local-only mode") + + yield + + # Shutdown + logger.info("🛑 Voicebot bot orchestrator shutting down") + +app = FastAPI(title="voicebot-bot-orchestrator", lifespan=lifespan) # Lightweight in-memory registry of running bot clients registry: Dict[str, WebRTCSignalingClient] = {} +# Log module import for debugging reloads +logger.info("đŸ“Ļ Bot orchestrator module imported/reloaded") + def discover_bots() -> Dict[str, Dict[str, Any]]: """Discover bot modules under the voicebot.bots package that expose bot_info. @@ -83,6 +121,8 @@ async def bot_join(bot_name: str, req: JoinRequest): create_tracks = bot.get("create_tracks") + logger.info(f"🤖 Bot {bot_name} joining lobby {req.lobby_id} with nick: '{req.nick}'") + # Start the WebRTCSignalingClient in a background asyncio task and register it client = WebRTCSignalingClient( server_url=req.server_url, @@ -104,8 +144,14 @@ async def bot_join(bot_name: str, req: JoinRequest): finally: registry.pop(run_id, None) - loop = asyncio.get_event_loop() - threading.Thread(target=loop.run_until_complete, args=(run_client(),), daemon=True).start() + def run_client_in_thread(): + """Run the client in a new event loop in a separate thread.""" + try: + asyncio.run(run_client()) + except Exception: + logger.exception("Bot client thread failed for run %s", run_id) + + threading.Thread(target=run_client_in_thread, daemon=True).start() return {"status": "started", "bot": bot_name, "run_id": run_id} @@ -141,8 +187,46 @@ def start_bot_api(host: str = "0.0.0.0", port: int = 8788): uvicorn.run(app, host=host, port=port) +def _construct_voicebot_url(host: str, port: str) -> str: + """Construct the voicebot URL based on host and port""" + if host == "0.0.0.0": + import socket + try: + hostname = socket.gethostname() + voicebot_url = f"http://{hostname}:{port}" + logger.info(f"🏠 Using hostname-based URL: {voicebot_url}") + except Exception: + voicebot_url = f"http://localhost:{port}" + logger.info(f"🏠 Using localhost URL: {voicebot_url}") + else: + voicebot_url = f"http://{host}:{port}" + logger.info(f"🏠 Using host-based URL: {voicebot_url}") + + return voicebot_url + + +def _perform_server_registration_sync(server_url: str, host: str, port: str, insecure: bool) -> str: + """Synchronous wrapper for _perform_server_registration""" + return asyncio.run(_perform_server_registration(server_url, host, port, insecure)) + + +async def _perform_server_registration(server_url: str, host: str, port: str, insecure: bool) -> str: + """Perform server registration with common logic""" + voicebot_url = _construct_voicebot_url(host, port) + + logger.info("âąī¸ Waiting 2 seconds before attempting remote registration...") + await asyncio.sleep(2) # Give server time to fully start + + provider_id = await register_with_server(server_url, voicebot_url, insecure) + logger.info(f"🎉 Successfully registered with remote server! Provider ID: {provider_id}") + return provider_id + + async def register_with_server(server_url: str, voicebot_url: str, insecure: bool = False) -> str: """Register this voicebot instance as a bot provider with the main server""" + logger.info(f"🔗 Attempting to register with remote server at {server_url}") + logger.info(f"📍 Registration details - Voicebot URL: {voicebot_url}, Insecure: {insecure}") + try: # Import httpx locally to avoid dependency issues import httpx @@ -153,6 +237,8 @@ async def register_with_server(server_url: str, voicebot_url: str, insecure: boo "description": "AI voicebot provider with speech recognition and synthetic media capabilities" } + logger.info(f"📤 Sending registration payload: {payload}") + # Prepare SSL context if needed verify = not insecure @@ -166,14 +252,15 @@ async def register_with_server(server_url: str, voicebot_url: str, insecure: boo if response.status_code == 200: result = response.json() provider_id = result.get("provider_id") - logger.info(f"Successfully registered with server as provider: {provider_id}") + logger.info(f"✅ Successfully registered with server as provider: {provider_id}") + logger.info(f"đŸŽ¯ Remote server can now discover bots at: {voicebot_url}") return provider_id else: - logger.error(f"Failed to register with server: HTTP {response.status_code}: {response.text}") + logger.error(f"❌ Failed to register with server: HTTP {response.status_code}: {response.text}") raise RuntimeError(f"Registration failed: {response.status_code}") except Exception as e: - logger.error(f"Error registering with server: {e}") + logger.error(f"đŸ’Ĩ Error registering with server: {e}") raise @@ -186,7 +273,6 @@ def start_bot_provider( ): """Start the bot provider API server and optionally register with main server""" import time - import socket # Start the FastAPI server in a background thread # Add reload functionality for development @@ -212,23 +298,19 @@ def start_bot_provider( # If server_url is provided, register with the main server if server_url: + logger.info(f"🔄 Server URL provided - will attempt registration with: {server_url}") # Give the server a moment to start + logger.info("âąī¸ Waiting 2 seconds for server to fully start...") time.sleep(2) - # Construct the voicebot URL - voicebot_url = f"http://{host}:{port}" - if host == "0.0.0.0": - # Try to get a better hostname - try: - hostname = socket.gethostname() - voicebot_url = f"http://{hostname}:{port}" - except Exception: - voicebot_url = f"http://localhost:{port}" - try: - asyncio.run(register_with_server(server_url, voicebot_url, insecure)) + provider_id = _perform_server_registration_sync(server_url, host, str(port), insecure) + logger.info(f"🎉 Registration completed successfully! Provider ID: {provider_id}") except Exception as e: - logger.error(f"Failed to register with server: {e}") + logger.error(f"❌ Failed to register with server: {e}") + logger.warning("âš ī¸ Bot orchestrator will continue running without remote registration") + else: + logger.info("â„šī¸ No remote server URL provided - running in local-only mode") # Keep the main thread alive try: diff --git a/voicebot/client_app.py b/voicebot/client_app.py index da4468b..192020f 100644 --- a/voicebot/client_app.py +++ b/voicebot/client_app.py @@ -8,6 +8,7 @@ import asyncio import os import fcntl import sys +import time from contextlib import asynccontextmanager from typing import Optional @@ -41,6 +42,9 @@ def create_client_app(args: VoicebotArgs) -> FastAPI: async def lifespan(app: FastAPI): nonlocal client_task, lock_file # Startup + logger.info(f"🚀 Voicebot client app started at {time.strftime('%Y-%m-%d %H:%M:%S')}") + logger.info(f"📡 Client config - Server: {args.server_url}, Lobby: {args.lobby}, Session: {args.session_name}") + # Use a file lock to prevent multiple instances from starting lock_file_path = "/tmp/voicebot_client.lock" diff --git a/voicebot/entrypoint.sh b/voicebot/entrypoint.sh index 4653b50..a5c7394 100644 --- a/voicebot/entrypoint.sh +++ b/voicebot/entrypoint.sh @@ -27,6 +27,8 @@ if [ "$PRODUCTION" != "true" ]; then if [ "$MODE" = "provider" ]; then echo "Running as bot provider with auto-reload..." export VOICEBOT_MODE=provider + export VOICEBOT_SERVER_URL="https://server:8000/ai-voicebot" + export VOICEBOT_SERVER_INSECURE="true" exec uv run uvicorn main:uvicorn_app \ --host 0.0.0.0 \ --port 8788 \ @@ -37,11 +39,11 @@ if [ "$PRODUCTION" != "true" ]; then else echo "Running as client (connecting to lobby)..." export VOICEBOT_MODE=client - export VOICEBOT_SERVER_URL="https://ketrenos.com/ai-voicebot" + export VOICEBOT_SERVER_URL="https://server:8000/ai-voicebot" + export VOICEBOT_SERVER_INSECURE="true" export VOICEBOT_LOBBY="default" export VOICEBOT_SESSION_NAME="Python Voicebot" export VOICEBOT_PASSWORD="v01c3b0t" - export VOICEBOT_INSECURE="true" exec uv run uvicorn main:uvicorn_app \ --host 0.0.0.0 \ --port 8789 \ @@ -55,6 +57,8 @@ else if [ "$MODE" = "provider" ]; then echo "Running as bot provider..." export VOICEBOT_MODE=provider + export VOICEBOT_SERVER_URL="https://server:8000/ai-voicebot" + export VOICEBOT_SERVER_INSECURE="true" exec uv run uvicorn main:uvicorn_app \ --host 0.0.0.0 \ --port 8788 \ @@ -62,11 +66,11 @@ else else echo "Running as client (connecting to lobby)..." export VOICEBOT_MODE=client - export VOICEBOT_SERVER_URL="https://ai-voicebot.ketrenos.com" + export VOICEBOT_SERVER_URL="https://server:8000/ai-voicebot" + export VOICEBOT_SERVER_INSECURE="true" export VOICEBOT_LOBBY="default" export VOICEBOT_SESSION_NAME="Python Voicebot" export VOICEBOT_PASSWORD="v01c3b0t" - export VOICEBOT_INSECURE="false" exec uv run uvicorn main:uvicorn_app \ --host 0.0.0.0 \ --port 8789 \ diff --git a/voicebot/main.py b/voicebot/main.py index 6267394..f58d4b2 100644 --- a/voicebot/main.py +++ b/voicebot/main.py @@ -13,11 +13,17 @@ import os # Add the parent directory to sys.path to allow absolute imports sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Import logger for reload debugging +from logger import logger + from voicebot.models import VoicebotArgs, VoicebotMode from voicebot.client_main import main_with_args, start_client_with_reload from voicebot.bot_orchestrator import start_bot_provider from voicebot.client_app import get_app +# Log module import for debugging reloads +logger.info("đŸ“Ļ Main module imported/reloaded") + # Create app instance for uvicorn import uvicorn_app = get_app() @@ -27,7 +33,7 @@ async def main(): parser = argparse.ArgumentParser(description="Python WebRTC voicebot client") parser.add_argument( "--server-url", - default="http://localhost:8000/ai-voicebot", + default="https://server:8000/ai-voicebot", help="AI-Voicebot lobby and signaling server base URL (http:// or https://)", ) parser.add_argument( diff --git a/voicebot/models.py b/voicebot/models.py index 9caea93..c7578a2 100644 --- a/voicebot/models.py +++ b/voicebot/models.py @@ -58,13 +58,13 @@ class VoicebotArgs(BaseModel): host=os.getenv('VOICEBOT_HOST', '0.0.0.0'), port=int(os.getenv('VOICEBOT_PORT', '8788')), reload=os.getenv('VOICEBOT_RELOAD', 'false').lower() == 'true', - server_url=os.getenv('VOICEBOT_SERVER_URL', 'http://localhost:8000/ai-voicebot'), + server_url=os.getenv('VOICEBOT_SERVER_URL', 'https://server:8000/ai-voicebot'), lobby=os.getenv('VOICEBOT_LOBBY', 'default'), session_name=os.getenv('VOICEBOT_SESSION_NAME', 'Python Bot'), session_id=os.getenv('VOICEBOT_SESSION_ID', None), password=os.getenv('VOICEBOT_PASSWORD', None), private=os.getenv('VOICEBOT_PRIVATE', 'false').lower() == 'true', - insecure=os.getenv('VOICEBOT_INSECURE', 'false').lower() == 'true', + insecure=os.getenv('VOICEBOT_SERVER_INSECURE', 'false').lower() == 'true', registration_check_interval=float(os.getenv('VOICEBOT_REGISTRATION_CHECK_INTERVAL', '30.0')) ) diff --git a/voicebot/webrtc_signaling.py b/voicebot/webrtc_signaling.py index 61436a0..8024f75 100644 --- a/voicebot/webrtc_signaling.py +++ b/voicebot/webrtc_signaling.py @@ -58,6 +58,17 @@ from voicebot.models import Peer, MessageData from voicebot.utils import create_ssl_context, log_network_info +def _convert_http_to_ws_url(url: str) -> str: + """Convert HTTP/HTTPS URL to WebSocket URL by replacing scheme.""" + if url.startswith("https://"): + return url.replace("https://", "wss://", 1) + elif url.startswith("http://"): + return url.replace("http://", "ws://", 1) + else: + # Assume it's already a WebSocket URL + return url + + class WebSocketProtocol(Protocol): def send(self, message: object, text: Optional[bool] = None) -> Awaitable[None]: ... def close(self, code: int = 1000, reason: str = "") -> Awaitable[None]: ... @@ -93,7 +104,7 @@ class WebRTCSignalingClient: self.websocket: Optional[object] = None # Optional password to register or takeover a name - self.name_password: Optional[str] = None + self.name_password: Optional[str] = session_name self.peers: dict[str, Peer] = {} self.peer_connections: dict[str, RTCPeerConnection] = {} @@ -120,18 +131,29 @@ class WebRTCSignalingClient: async def connect(self): """Connect to the signaling server""" - ws_url = f"{self.server_url}/ws/lobby/{self.lobby_id}/{self.session_id}" + base_ws_url = _convert_http_to_ws_url(self.server_url) + ws_url = f"{base_ws_url}/ws/lobby/{self.lobby_id}/{self.session_id}" logger.info(f"Connecting to signaling server: {ws_url}") # Log network information for debugging log_network_info() try: - # If insecure (self-signed certs), create an SSL context for the websocket - ws_ssl = create_ssl_context(self.insecure) + # Create SSL context based on URL scheme and insecure setting + if ws_url.startswith("wss://"): + # For wss://, we need an SSL context + if self.insecure: + # Accept self-signed certificates + ws_ssl = create_ssl_context(insecure=True) + else: + # Use default SSL context for secure connections + ws_ssl = True + else: + # For ws://, no SSL context needed + ws_ssl = None logger.info( - f"Attempting websocket connection to {ws_url} with ssl={bool(ws_ssl)}" + f"Attempting websocket connection to {ws_url} with ssl={ws_ssl}" ) self.websocket = await websockets.connect(ws_url, ssl=ws_ssl) logger.info("Connected to signaling server") @@ -263,10 +285,21 @@ class WebRTCSignalingClient: self.websocket = None # Reconnect - ws_url = f"{self.server_url}/ws/lobby/{self.lobby_id}/{self.session_id}" + base_ws_url = _convert_http_to_ws_url(self.server_url) + ws_url = f"{base_ws_url}/ws/lobby/{self.lobby_id}/{self.session_id}" - # If insecure (self-signed certs), create an SSL context for the websocket - ws_ssl = create_ssl_context(self.insecure) + # Create SSL context based on URL scheme and insecure setting + if ws_url.startswith("wss://"): + # For wss://, we need an SSL context + if self.insecure: + # Accept self-signed certificates + ws_ssl = create_ssl_context(insecure=True) + else: + # Use default SSL context for secure connections + ws_ssl = True + else: + # For ws://, no SSL context needed + ws_ssl = None logger.info(f"Reconnecting to signaling server: {ws_url}") self.websocket = await websockets.connect(ws_url, ssl=ws_ssl) @@ -377,7 +410,13 @@ class WebRTCSignalingClient: async def _process_message(self, message: MessageData): """Process incoming signaling messages""" try: - # Validate the base message structure first + # Handle error messages specially since they have a different structure + if message.get("type") == "error" and "error" in message: + error_msg = message.get("error", "Unknown error") + logger.error(f"Received error from signaling server: {error_msg}") + return + + # Validate the base message structure for non-error messages validated_message = WebSocketMessageModel.model_validate(message) msg_type = validated_message.type data = validated_message.data @@ -448,6 +487,10 @@ class WebRTCSignalingClient: logger.error(f"Invalid update payload: {e}", exc_info=True) return logger.info(f"Received update message: {validated}") + elif msg_type == "status_check": + # Handle status check messages - these are used to verify connection + logger.debug(f"Received status check message: {data}") + # No special processing needed for status checks, just acknowledge receipt else: logger.info(f"Unhandled message type: {msg_type} with data: {data}")