ai-voicebot/voicebot/bot_orchestrator.py
James Ketrenos 9ce3d1b670 Implement comprehensive chat integration for voicebot system
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
2025-09-03 16:28:32 -07:00

366 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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...")