Bots now join on demand

This commit is contained in:
James Ketr 2025-09-03 15:51:47 -07:00
parent b916db243b
commit b5614b9d99
10 changed files with 191 additions and 66 deletions

View File

@ -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

View File

@ -182,14 +182,10 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
<Typography variant="body2" component="div">
{botInfo.description}
</Typography>
<Chip
label={providerName}
size="small"
variant="outlined"
sx={{ mt: 0.5 }}
/>
<Chip label={providerName} size="small" variant="outlined" sx={{ mt: 0.5 }} />
</Box>
}
secondaryTypographyProps={{ component: "div" }}
/>
</ListItem>
);
@ -220,6 +216,7 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
</Typography>
</Box>
}
secondaryTypographyProps={{ component: "div" }}
/>
</ListItem>
))}
@ -258,15 +255,8 @@ const BotManager: React.FC<BotManagerProps> = ({ lobbyId, onBotAdded, sx }) => {
}}
onClick={() => setSelectedBot(botName)}
>
<ListItemText
primary={botInfo.name}
secondary={botInfo.description}
/>
<Chip
label={getProviderName(providers[botName])}
size="small"
variant="outlined"
/>
<ListItemText primary={botInfo.name} secondary={botInfo.description} />
<Chip label={getProviderName(providers[botName])} size="small" variant="outlined" />
</ListItem>
))}
</List>

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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"

View File

@ -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 \

View File

@ -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(

View File

@ -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'))
)

View File

@ -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}")