Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
4.5 KiB
Python
140 lines
4.5 KiB
Python
"""WebSocket API — Real-time call events and audio classification stream."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
|
|
from api.deps import get_gateway
|
|
from config import get_settings
|
|
from models.events import EventType, GatewayEvent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def _authorize(websocket: WebSocket) -> bool:
|
|
"""
|
|
Check the static bearer token before accepting the socket.
|
|
|
|
Browsers can't set headers on WebSocket connects, so a `token`
|
|
query parameter is accepted alongside the Authorization header.
|
|
"""
|
|
token = get_settings().api_token.get_secret_value()
|
|
if not token:
|
|
return True
|
|
supplied = websocket.query_params.get("token", "")
|
|
auth = websocket.headers.get("authorization", "")
|
|
if auth.lower().startswith("bearer "):
|
|
supplied = auth[7:]
|
|
if secrets.compare_digest(supplied, token):
|
|
return True
|
|
await websocket.close(code=4401, reason="Missing or invalid bearer token")
|
|
return False
|
|
|
|
|
|
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!"
|
|
}
|
|
"""
|
|
if not await _authorize(websocket):
|
|
return
|
|
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(replay_last=25)
|
|
|
|
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.
|
|
"""
|
|
if not await _authorize(websocket):
|
|
return
|
|
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()
|