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:
1
api/__init__.py
Normal file
1
api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""REST API endpoints for the Hold Slayer Gateway."""
|
||||
214
api/call_flows.py
Normal file
214
api/call_flows.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Call Flows API — Store and manage IVR navigation trees.
|
||||
|
||||
The system gets smarter every time you call somewhere.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from slugify import slugify
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_gateway
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import StoredCallFlow, get_db
|
||||
from models.call_flow import (
|
||||
CallFlow,
|
||||
CallFlowCreate,
|
||||
CallFlowStep,
|
||||
CallFlowSummary,
|
||||
CallFlowUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=CallFlow)
|
||||
async def create_call_flow(
|
||||
flow: CallFlowCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Store a new call flow for a phone number."""
|
||||
flow_id = slugify(flow.name)
|
||||
|
||||
# Check if ID already exists
|
||||
existing = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Call flow '{flow_id}' already exists. Use PUT to update.",
|
||||
)
|
||||
|
||||
db_flow = StoredCallFlow(
|
||||
id=flow_id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=[s.model_dump() for s in flow.steps],
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
db.add(db_flow)
|
||||
await db.flush()
|
||||
|
||||
return CallFlow(
|
||||
id=flow_id,
|
||||
name=flow.name,
|
||||
phone_number=flow.phone_number,
|
||||
description=flow.description,
|
||||
steps=flow.steps,
|
||||
tags=flow.tags,
|
||||
notes=flow.notes,
|
||||
last_verified=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CallFlowSummary])
|
||||
async def list_call_flows(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all stored call flows."""
|
||||
result = await db.execute(select(StoredCallFlow))
|
||||
rows = result.scalars().all()
|
||||
|
||||
return [
|
||||
CallFlowSummary(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
step_count=len(row.steps) if row.steps else 0,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
tags=row.tags or [],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{flow_id}", response_model=CallFlow)
|
||||
async def get_call_flow(
|
||||
flow_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a stored call flow by ID."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-number/{phone_number}", response_model=CallFlow)
|
||||
async def get_flow_for_number(
|
||||
phone_number: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Look up stored call flow by phone number."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No call flow found for {phone_number}",
|
||||
)
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{flow_id}", response_model=CallFlow)
|
||||
async def update_call_flow(
|
||||
flow_id: str,
|
||||
update: CallFlowUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update an existing call flow."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
if update.name is not None:
|
||||
row.name = update.name
|
||||
if update.description is not None:
|
||||
row.description = update.description
|
||||
if update.steps is not None:
|
||||
row.steps = [s.model_dump() for s in update.steps]
|
||||
if update.tags is not None:
|
||||
row.tags = update.tags
|
||||
if update.notes is not None:
|
||||
row.notes = update.notes
|
||||
if update.last_verified is not None:
|
||||
row.last_verified = update.last_verified
|
||||
|
||||
await db.flush()
|
||||
|
||||
return CallFlow(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
phone_number=row.phone_number,
|
||||
description=row.description or "",
|
||||
steps=[CallFlowStep(**s) for s in row.steps],
|
||||
tags=row.tags or [],
|
||||
notes=row.notes,
|
||||
avg_hold_time=row.avg_hold_time,
|
||||
success_rate=row.success_rate,
|
||||
last_used=row.last_used,
|
||||
times_used=row.times_used or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{flow_id}")
|
||||
async def delete_call_flow(
|
||||
flow_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a stored call flow."""
|
||||
result = await db.execute(
|
||||
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
|
||||
|
||||
await db.delete(row)
|
||||
return {"status": "deleted", "flow_id": flow_id}
|
||||
177
api/calls.py
Normal file
177
api/calls.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Call Management API — Place calls, check status, transfer, hold-slay.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.deps import get_gateway
|
||||
from core.gateway import AIPSTNGateway
|
||||
from models.call import (
|
||||
CallMode,
|
||||
CallRequest,
|
||||
CallResponse,
|
||||
CallStatusResponse,
|
||||
HoldSlayerRequest,
|
||||
TransferRequest,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/outbound", response_model=CallResponse)
|
||||
async def make_call(
|
||||
request: CallRequest,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""
|
||||
Place an outbound call.
|
||||
|
||||
Modes:
|
||||
- **direct**: Call and connect to your device immediately
|
||||
- **hold_slayer**: Navigate IVR, wait on hold, transfer when human detected
|
||||
- **ai_assisted**: Connect with noise cancel, transcription, recording
|
||||
"""
|
||||
try:
|
||||
call = await gateway.make_call(
|
||||
number=request.number,
|
||||
mode=request.mode,
|
||||
intent=request.intent,
|
||||
device=request.device,
|
||||
call_flow_id=request.call_flow_id,
|
||||
services=request.services,
|
||||
)
|
||||
return CallResponse(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
number=request.number,
|
||||
mode=request.mode.value,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/hold-slayer", response_model=CallResponse)
|
||||
async def hold_slayer(
|
||||
request: HoldSlayerRequest,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""
|
||||
🗡️ The Hold Slayer endpoint.
|
||||
|
||||
Give it a number and intent, it calls, navigates the IVR,
|
||||
waits on hold, and rings you when a human picks up.
|
||||
|
||||
Example:
|
||||
POST /api/calls/hold-slayer
|
||||
{
|
||||
"number": "+18005551234",
|
||||
"intent": "cancel my credit card",
|
||||
"call_flow_id": "chase_bank_main",
|
||||
"transfer_to": "sip_phone",
|
||||
"notify": ["sms", "push"]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
call = await gateway.make_call(
|
||||
number=request.number,
|
||||
mode=CallMode.HOLD_SLAYER,
|
||||
intent=request.intent,
|
||||
call_flow_id=request.call_flow_id,
|
||||
device=request.transfer_to,
|
||||
)
|
||||
return CallResponse(
|
||||
call_id=call.id,
|
||||
status="navigating_ivr",
|
||||
number=request.number,
|
||||
mode="hold_slayer",
|
||||
message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def list_active_calls(
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""List all active calls with their current status."""
|
||||
calls = gateway.call_manager.active_calls
|
||||
return [call.summary() for call in calls.values()]
|
||||
|
||||
|
||||
@router.get("/{call_id}", response_model=CallStatusResponse)
|
||||
async def get_call(
|
||||
call_id: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""Get current call status, transcript so far, classification history."""
|
||||
call = gateway.get_call(call_id)
|
||||
if not call:
|
||||
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
|
||||
|
||||
return CallStatusResponse(
|
||||
call_id=call.id,
|
||||
status=call.status.value,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
mode=call.mode.value,
|
||||
duration=call.duration,
|
||||
hold_time=call.hold_time,
|
||||
audio_type=call.current_classification.value,
|
||||
intent=call.intent,
|
||||
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
|
||||
classification_history=call.classification_history[-50:],
|
||||
current_step=call.current_step_id,
|
||||
services=call.services,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{call_id}/transfer")
|
||||
async def transfer_call(
|
||||
call_id: str,
|
||||
request: TransferRequest,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""Transfer an active call to a device."""
|
||||
try:
|
||||
await gateway.transfer_call(call_id, request.device)
|
||||
return {"status": "transferred", "target": request.device}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{call_id}/hangup")
|
||||
async def hangup_call(
|
||||
call_id: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""Hang up a call."""
|
||||
try:
|
||||
await gateway.hangup_call(call_id)
|
||||
return {"status": "hung_up", "call_id": call_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{call_id}/dtmf")
|
||||
async def send_dtmf(
|
||||
call_id: str,
|
||||
digits: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""Send DTMF tones on an active call."""
|
||||
call = gateway.get_call(call_id)
|
||||
if not call:
|
||||
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
|
||||
|
||||
# Find the PSTN leg for this call
|
||||
for leg_id, cid in gateway.call_manager._call_legs.items():
|
||||
if cid == call_id:
|
||||
await gateway.sip_engine.send_dtmf(leg_id, digits)
|
||||
return {"status": "sent", "digits": digits}
|
||||
|
||||
raise HTTPException(status_code=500, detail="No active SIP leg found for this call")
|
||||
17
api/deps.py
Normal file
17
api/deps.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
API Dependencies — Shared dependency injection for all routes.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import get_db
|
||||
|
||||
|
||||
def get_gateway(request: Request) -> AIPSTNGateway:
|
||||
"""Get the gateway instance from app state."""
|
||||
gateway = getattr(request.app.state, "gateway", None)
|
||||
if gateway is None:
|
||||
raise HTTPException(status_code=503, detail="Gateway not initialized")
|
||||
return gateway
|
||||
131
api/devices.py
Normal file
131
api/devices.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Device Management API — Register and manage phones/softphones.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_gateway
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Device as DeviceDB
|
||||
from db.database import get_db
|
||||
from models.device import Device, DeviceCreate, DeviceStatus, DeviceUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=Device)
|
||||
async def register_device(
|
||||
device: DeviceCreate,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Register a new device with the gateway."""
|
||||
device_id = f"dev_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Save to DB
|
||||
db_device = DeviceDB(
|
||||
id=device_id,
|
||||
name=device.name,
|
||||
type=device.type.value,
|
||||
sip_uri=device.sip_uri,
|
||||
phone_number=device.phone_number,
|
||||
priority=device.priority,
|
||||
capabilities=device.capabilities,
|
||||
is_online="false",
|
||||
)
|
||||
db.add(db_device)
|
||||
await db.flush()
|
||||
|
||||
# Register with gateway
|
||||
dev = Device(id=device_id, **device.model_dump())
|
||||
gateway.register_device(dev)
|
||||
|
||||
return dev
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DeviceStatus])
|
||||
async def list_devices(
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""List all registered devices and their status."""
|
||||
return [
|
||||
DeviceStatus(
|
||||
id=d.id,
|
||||
name=d.name,
|
||||
type=d.type,
|
||||
is_online=d.is_online,
|
||||
last_seen=d.last_seen,
|
||||
can_receive_call=d.can_receive_call,
|
||||
)
|
||||
for d in gateway.devices.values()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{device_id}", response_model=Device)
|
||||
async def get_device(
|
||||
device_id: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
):
|
||||
"""Get a specific device."""
|
||||
device = gateway.devices.get(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
return device
|
||||
|
||||
|
||||
@router.put("/{device_id}", response_model=Device)
|
||||
async def update_device(
|
||||
device_id: str,
|
||||
update: DeviceUpdate,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a device."""
|
||||
device = gateway.devices.get(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
# Update in-memory
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(device, key, value)
|
||||
|
||||
# Update in DB
|
||||
result = await db.execute(
|
||||
select(DeviceDB).where(DeviceDB.id == device_id)
|
||||
)
|
||||
db_device = result.scalar_one_or_none()
|
||||
if db_device:
|
||||
for key, value in update_data.items():
|
||||
if key == "type" and value is not None:
|
||||
value = value.value if hasattr(value, "value") else value
|
||||
setattr(db_device, key, value)
|
||||
|
||||
return device
|
||||
|
||||
|
||||
@router.delete("/{device_id}")
|
||||
async def unregister_device(
|
||||
device_id: str,
|
||||
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unregister a device."""
|
||||
if device_id not in gateway.devices:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
|
||||
gateway.unregister_device(device_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(DeviceDB).where(DeviceDB.id == device_id)
|
||||
)
|
||||
db_device = result.scalar_one_or_none()
|
||||
if db_device:
|
||||
await db.delete(db_device)
|
||||
|
||||
return {"status": "unregistered", "device_id": device_id}
|
||||
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