Files
hold-slayer/api/websocket.py
Robert Helewka 4048ce1db6 Stage 4: honest health, explicit error policy, event-bus integrity
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>
2026-07-10 07:01:45 -04:00

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