Features added: - WebSocket chat message handling in WebRTC signaling client - Bot chat handler discovery and automatic setup - Chat message sending/receiving capabilities - Example chatbot with conversation features - Enhanced whisper bot with chat commands - Comprehensive error handling and logging - Full integration with existing WebRTC infrastructure Bots can now: - Receive chat messages from lobby participants - Send responses back through WebSocket - Process commands and keywords - Integrate seamlessly with voice/video functionality Files modified: - voicebot/webrtc_signaling.py: Added chat message handling - voicebot/bot_orchestrator.py: Enhanced bot discovery for chat - voicebot/bots/whisper.py: Added chat command processing - voicebot/bots/chatbot.py: New conversational bot - voicebot/bots/__init__.py: Added chatbot module - CHAT_INTEGRATION.md: Comprehensive documentation - README.md: Updated with chat functionality info
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""
|
||
Bot orchestrator FastAPI service.
|
||
|
||
This module provides the FastAPI service for bot discovery and orchestration.
|
||
"""
|
||
|
||
import asyncio
|
||
import threading
|
||
import uuid
|
||
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
|
||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
import uvicorn
|
||
from fastapi import FastAPI, HTTPException
|
||
|
||
from logger import logger
|
||
from voicebot.models import JoinRequest
|
||
from voicebot.webrtc_signaling import WebRTCSignalingClient
|
||
|
||
# Add shared models import for chat types
|
||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
from shared.models import ChatMessageModel
|
||
|
||
|
||
@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.
|
||
|
||
This intentionally imports modules under `voicebot.bots` so heavy bot
|
||
implementations can remain in that package and be imported lazily.
|
||
"""
|
||
bots: Dict[str, Dict[str, Any]] = {}
|
||
try:
|
||
package = importlib.import_module("voicebot.bots")
|
||
package_path = package.__path__
|
||
except Exception:
|
||
logger.exception("Failed to import voicebot.bots package")
|
||
return bots
|
||
|
||
for _finder, name, _ispkg in pkgutil.iter_modules(package_path):
|
||
try:
|
||
mod = importlib.import_module(f"voicebot.bots.{name}")
|
||
except Exception:
|
||
logger.exception("Failed to import voicebot.bots.%s", name)
|
||
continue
|
||
info = None
|
||
create_tracks = None
|
||
if hasattr(mod, "agent_info") and callable(getattr(mod, "agent_info")):
|
||
try:
|
||
info = mod.agent_info()
|
||
# Note: Keep copy as is to maintain structure
|
||
except Exception:
|
||
logger.exception("agent_info() failed for %s", name)
|
||
if hasattr(mod, "create_agent_tracks") and callable(getattr(mod, "create_agent_tracks")):
|
||
create_tracks = getattr(mod, "create_agent_tracks")
|
||
|
||
if info:
|
||
# Check for chat handler
|
||
chat_handler = None
|
||
if hasattr(mod, "handle_chat_message") and callable(getattr(mod, "handle_chat_message")):
|
||
chat_handler = getattr(mod, "handle_chat_message")
|
||
|
||
bots[info.get("name", name)] = {
|
||
"module": name,
|
||
"info": info,
|
||
"create_tracks": create_tracks,
|
||
"chat_handler": chat_handler
|
||
}
|
||
return bots
|
||
|
||
|
||
@app.get("/bots")
|
||
def list_bots() -> Dict[str, Any]:
|
||
"""List available bots."""
|
||
bots = discover_bots()
|
||
return {k: v["info"] for k, v in bots.items()}
|
||
|
||
|
||
@app.post("/bots/{bot_name}/join")
|
||
async def bot_join(bot_name: str, req: JoinRequest):
|
||
"""Make a bot join a lobby."""
|
||
bots = discover_bots()
|
||
bot = bots.get(bot_name)
|
||
if not bot:
|
||
raise HTTPException(status_code=404, detail="Bot not found")
|
||
|
||
create_tracks = bot.get("create_tracks")
|
||
chat_handler = bot.get("chat_handler")
|
||
|
||
logger.info(f"🤖 Bot {bot_name} joining lobby {req.lobby_id} with nick: '{req.nick}'")
|
||
if chat_handler:
|
||
logger.info(f"🤖 Bot {bot_name} has chat handling capabilities")
|
||
|
||
# Start the WebRTCSignalingClient in a background asyncio task and register it
|
||
client = WebRTCSignalingClient(
|
||
server_url=req.server_url,
|
||
lobby_id=req.lobby_id,
|
||
session_id=req.session_id,
|
||
session_name=req.nick,
|
||
insecure=req.insecure,
|
||
create_tracks=create_tracks,
|
||
)
|
||
|
||
# Set up chat message handler if the bot provides one
|
||
if chat_handler:
|
||
async def bot_chat_handler(chat_message: ChatMessageModel):
|
||
"""Wrapper to call the bot's chat handler and optionally send responses"""
|
||
try:
|
||
# Call the bot's chat handler - it may return a response message
|
||
response = await chat_handler(chat_message, client.send_chat_message)
|
||
if response and isinstance(response, str):
|
||
await client.send_chat_message(response)
|
||
except Exception as e:
|
||
logger.error(f"Error in bot chat handler for {bot_name}: {e}", exc_info=True)
|
||
|
||
client.on_chat_message_received = bot_chat_handler
|
||
|
||
run_id = str(uuid.uuid4())
|
||
|
||
async def run_client():
|
||
try:
|
||
registry[run_id] = client
|
||
await client.connect()
|
||
except Exception:
|
||
logger.exception("Bot client failed for run %s", run_id)
|
||
finally:
|
||
registry.pop(run_id, None)
|
||
|
||
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}
|
||
|
||
|
||
@app.post("/bots/runs/{run_id}/stop")
|
||
async def stop_run(run_id: str):
|
||
"""Stop a running bot."""
|
||
client = registry.get(run_id)
|
||
if not client:
|
||
raise HTTPException(status_code=404, detail="Run not found")
|
||
|
||
try:
|
||
# Request graceful shutdown instead of awaiting disconnect from different loop
|
||
client.request_shutdown()
|
||
|
||
# Give the client a moment to shutdown gracefully
|
||
await asyncio.sleep(0.5)
|
||
|
||
# Remove from registry
|
||
registry.pop(run_id, None)
|
||
|
||
return {"status": "stopped", "run_id": run_id}
|
||
except Exception:
|
||
logger.exception("Failed to stop run %s", run_id)
|
||
# Still remove from registry even if shutdown failed
|
||
registry.pop(run_id, None)
|
||
raise HTTPException(status_code=500, detail="Failed to stop run")
|
||
|
||
|
||
@app.get("/bots/runs")
|
||
def list_runs() -> Dict[str, Any]:
|
||
"""List running bot instances."""
|
||
return {
|
||
"runs": [
|
||
{"run_id": run_id, "session_id": client.session_id, "session_name": client.session_name}
|
||
for run_id, client in registry.items()
|
||
]
|
||
}
|
||
|
||
|
||
def start_bot_api(host: str = "0.0.0.0", port: int = 8788):
|
||
"""Start the bot orchestration API server"""
|
||
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
|
||
|
||
# Get provider key from environment variable
|
||
provider_key = os.getenv('VOICEBOT_PROVIDER_KEY', 'default-voicebot')
|
||
|
||
payload = {
|
||
"base_url": voicebot_url.rstrip('/'),
|
||
"name": "voicebot-provider",
|
||
"description": "AI voicebot provider with speech recognition and synthetic media capabilities",
|
||
"provider_key": provider_key
|
||
}
|
||
|
||
logger.info(f"📤 Sending registration payload: {payload}")
|
||
|
||
# Prepare SSL context if needed
|
||
verify = not insecure
|
||
|
||
async with httpx.AsyncClient(verify=verify) as client:
|
||
response = await client.post(
|
||
f"{server_url}/api/bots/providers/register",
|
||
json=payload,
|
||
timeout=10.0
|
||
)
|
||
|
||
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"🎯 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}")
|
||
raise RuntimeError(f"Registration failed: {response.status_code}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"💥 Error registering with server: {e}")
|
||
raise
|
||
|
||
|
||
def start_bot_provider(
|
||
host: str = "0.0.0.0",
|
||
port: int = 8788,
|
||
server_url: str | None = None,
|
||
insecure: bool = False,
|
||
reload: bool = False
|
||
):
|
||
"""Start the bot provider API server and optionally register with main server"""
|
||
import time
|
||
|
||
# Start the FastAPI server in a background thread
|
||
# Add reload functionality for development
|
||
if reload:
|
||
server_thread = threading.Thread(
|
||
target=lambda: uvicorn.run(
|
||
app,
|
||
host=host,
|
||
port=port,
|
||
log_level="info",
|
||
reload=True,
|
||
reload_dirs=["/voicebot", "/shared"]
|
||
),
|
||
daemon=True
|
||
)
|
||
else:
|
||
server_thread = threading.Thread(
|
||
target=lambda: uvicorn.run(app, host=host, port=port, log_level="info"),
|
||
daemon=True
|
||
)
|
||
logger.info(f"Starting bot provider API server on {host}:{port}...")
|
||
server_thread.start()
|
||
|
||
# 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)
|
||
|
||
try:
|
||
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.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:
|
||
while True:
|
||
time.sleep(1)
|
||
except KeyboardInterrupt:
|
||
logger.info("Shutting down bot provider...")
|