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:
2026-03-21 19:23:26 +00:00
parent c9ff60702b
commit ecf37658ce
56 changed files with 11601 additions and 164 deletions

1
api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""REST API endpoints for the Hold Slayer Gateway."""

214
api/call_flows.py Normal file
View 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
View 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
View 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
View 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
View 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()