feat: add initial Hold Slayer AI telephony gateway implementation
Complete project scaffolding and core implementation of an AI-powered telephony system that calls companies, navigates IVR menus, waits on hold, and transfers to the user when a human answers. Key components: - FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces - SIP/VoIP call management via PJSUA2 with RTP audio streaming - LLM-powered IVR navigation using OpenAI/Anthropic with tool calling - Hold detection service combining audio analysis and silence detection - Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines - Call recording with per-channel and mixed audio capture - Event bus (asyncio pub/sub) for real-time client updates - Web dashboard with live call monitoring - SQLite persistence via SQLAlchemy with call history and analytics - Notification support (email, SMS, webhook, desktop) - Docker Compose deployment with Opal VoIP and Opal Media containers - Comprehensive test suite with unit, integration, and E2E tests - Simplified .gitignore and full project documentation in README
This commit is contained in:
113
api/websocket.py
Normal file
113
api/websocket.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""WebSocket API — Real-time call events and audio classification stream."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.deps import get_gateway
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _send_trunk_status(websocket: WebSocket, gateway) -> None:
|
||||
"""Send current SIP trunk status as a synthetic event to a newly connected client."""
|
||||
try:
|
||||
trunk_status = await gateway.sip_engine.get_trunk_status()
|
||||
registered = trunk_status.get("registered", False)
|
||||
event_type = (
|
||||
EventType.SIP_TRUNK_REGISTERED if registered
|
||||
else EventType.SIP_TRUNK_REGISTRATION_FAILED
|
||||
)
|
||||
reason = trunk_status.get("reason", "Trunk registration failed or not configured")
|
||||
event = GatewayEvent(
|
||||
type=event_type,
|
||||
message=(
|
||||
f"SIP trunk registered with {trunk_status.get('host')}"
|
||||
if registered
|
||||
else f"SIP trunk not registered — {reason}"
|
||||
),
|
||||
data=trunk_status,
|
||||
)
|
||||
await websocket.send_json(event.to_ws_message())
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not send trunk status on connect: {exc}")
|
||||
|
||||
|
||||
@router.websocket("/events")
|
||||
async def event_stream(websocket: WebSocket):
|
||||
"""
|
||||
Real-time event stream.
|
||||
|
||||
Sends all gateway events as JSON:
|
||||
- Call lifecycle (initiated, ringing, connected, ended)
|
||||
- Hold Slayer events (IVR steps, DTMF, hold detected, human detected)
|
||||
- Audio classifications
|
||||
- Transcript chunks
|
||||
- Device status changes
|
||||
|
||||
Example message:
|
||||
{
|
||||
"type": "holdslayer.human_detected",
|
||||
"call_id": "call_abc123",
|
||||
"timestamp": "2025-01-15T14:30:00",
|
||||
"data": {"audio_type": "live_human", "confidence": 0.92},
|
||||
"message": "🚨 Human detected!"
|
||||
}
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info("WebSocket client connected")
|
||||
|
||||
gateway = getattr(websocket.app.state, "gateway", None)
|
||||
if not gateway:
|
||||
await websocket.send_json({"error": "Gateway not initialized"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Immediately push current trunk status so the dashboard doesn't start blank
|
||||
await _send_trunk_status(websocket, gateway)
|
||||
|
||||
subscription = gateway.event_bus.subscribe()
|
||||
|
||||
try:
|
||||
async for event in subscription:
|
||||
await websocket.send_json(event.to_ws_message())
|
||||
except WebSocketDisconnect:
|
||||
logger.info("WebSocket client disconnected")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
finally:
|
||||
subscription.close()
|
||||
|
||||
|
||||
@router.websocket("/calls/{call_id}/events")
|
||||
async def call_event_stream(websocket: WebSocket, call_id: str):
|
||||
"""
|
||||
Event stream filtered to a specific call.
|
||||
|
||||
Same format as /events but only sends events for the specified call.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket client connected for call {call_id}")
|
||||
|
||||
gateway = getattr(websocket.app.state, "gateway", None)
|
||||
if not gateway:
|
||||
await websocket.send_json({"error": "Gateway not initialized"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
subscription = gateway.event_bus.subscribe()
|
||||
|
||||
try:
|
||||
async for event in subscription:
|
||||
if event.call_id == call_id:
|
||||
await websocket.send_json(event.to_ws_message())
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket client disconnected for call {call_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
finally:
|
||||
subscription.close()
|
||||
Reference in New Issue
Block a user