Files
hold-slayer/core/gateway.py
Robert Helewka 2a05be27bf feat(sip): add PJSUA2 engine — audio finally reaches the classifier
The gateway can now hear. Verified end to end against the Asterisk lab:
speech classifies as live_human, hold music as music, the speech→music
transition tracks the dialplan, and DTMF reaches a real IVR (Asterisk logged
"caller pressed 1 -> accounts" and branched). RTP stats show 0% packet loss.

PJSUAEngine implements the existing SIPEngine interface, so the gateway,
call manager and hold-slayer service are unchanged. It is selected with
SIP_ENGINE=pjsua2; the default stays "sippy" while this is proven, and the
mock remains opt-in as before.

Why a new engine rather than fixing the old path: PJSUA2 exposes no
standalone RTP media object, so it will not surface media for a dialog it
does not own. Owning the dialog is the price of owning the media. Sippy keeps
the SBC roles it is good at — device registration, routing, leg bridging —
and SippyEngine remains fully functional for signalling; it simply cannot
carry media, which its media branch now says plainly instead of calling a
method that could never work.

The safety invariants are untouched. This engine is reachable only through
gateway.make_call, which refuses emergency numbers and enforces the
concurrency cap before any SIP action. No new dial path was introduced.

MediaPipeline.add_remote_stream(host, port) is replaced by
attach_call_media(stream_id, audio_media), called from onCallMediaState —
the one place PJSUA2 hands out RTP-backed media. Taps requested before media
comes up are attached when it does, so the classifier never misses the start
of a call.

Three crash/lifetime bugs found by running against the real bindings, none of
which any unit test would have caught:

- pj.Call and pj.Account objects finalised after libDestroy() abort the
  process on a native assertion, exactly as media ports do. Both are now
  dropped and collected before the pipeline destroys the endpoint.
- PJSUA2 keeps delivering callbacks during interpreter teardown, when module
  globals may already be cleared. The callbacks alias what they need locally
  and swallow everything: a raise there escapes into C++ and takes the worker
  thread with it.
- hangup() only queues the BYE, so shutdown deleted the account with a call
  still active and left the far end on an unclosed dialog. stop() now waits
  briefly for the teardown to complete.

Threading follows the established rule: PJSUA2 worker threads reach the loop
only through _post_from_pj → run_coroutine_threadsafe, and any thread PJSUA2
did not create registers itself before touching a PJSUA2 object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 07:06:56 -04:00

499 lines
18 KiB
Python

"""
AI PSTN Gateway — call operations and device registry.
The application service that FastAPI and MCP talk to for live-call
work. Composition happens in main.py's lifespan: services are built
there and attached; this module never imports from services/.
"""
import asyncio
import logging
from collections.abc import Callable
from datetime import datetime
from typing import Optional
from config import Settings
from core.call_manager import CallManager
from core.dial_plan import is_emergency_number, next_extension
from core.event_bus import EventBus
from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine, SIPEngine
from core.sippy_engine import SippyEngine
from models.call import ActiveCall, CallMode, CallStatus
from models.device import Device, DeviceType
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
def build_sip_engine(
settings: Settings,
media_pipeline: MediaPipeline,
on_leg_state_change: Callable,
on_device_registered: Callable,
on_incoming_call: Callable,
) -> SIPEngine:
"""
Build the SIP engine from config.
The mock engine must be requested explicitly (USE_MOCK_SIP=true).
An unconfigured trunk or a failed SippyEngine construction raises —
the caller fails startup rather than running a gateway that can't
place real calls while reporting healthy.
"""
if settings.use_mock_sip:
logger.warning("🧪 USE_MOCK_SIP=true — SIP engine is a mock, no real calls")
return MockSIPEngine()
trunk = settings.sip_trunk
gw_sip = settings.gateway_sip
if not trunk.host or trunk.host in ("sip.provider.com", "sip.yourprovider.com"):
raise RuntimeError(
"SIP trunk is not configured (SIP_TRUNK_HOST is unset or a "
"placeholder). Set SIP_TRUNK_* in .env, or set USE_MOCK_SIP=true "
"for development without a trunk."
)
if settings.sip_engine.lower() == "pjsua2":
from core.pjsua_engine import PJSUAEngine
logger.info("📞 SIP engine: PJSUA2 (call control + media)")
return PJSUAEngine(
sip_address=gw_sip.host,
sip_port=gw_sip.port,
trunk_host=trunk.host,
trunk_port=trunk.port,
trunk_username=trunk.username,
trunk_password=trunk.password.get_secret_value(),
trunk_transport=trunk.transport,
domain=gw_sip.domain,
did=trunk.did,
media_pipeline=media_pipeline,
on_leg_state_change=on_leg_state_change,
on_device_registered=on_device_registered,
on_incoming_call=on_incoming_call,
)
return SippyEngine(
sip_address=gw_sip.host,
sip_port=gw_sip.port,
trunk_host=trunk.host,
trunk_port=trunk.port,
trunk_username=trunk.username,
trunk_password=trunk.password.get_secret_value(),
trunk_transport=trunk.transport,
domain=gw_sip.domain,
did=trunk.did,
media_pipeline=media_pipeline,
on_leg_state_change=on_leg_state_change,
on_device_registered=on_device_registered,
on_incoming_call=on_incoming_call,
)
class AIPSTNGateway:
"""
The AI PSTN Gateway.
Owns live-call operations (make/transfer/hangup), the device
registry, and per-call background tasks. Services are attached by
the composition root; mode handlers launch per-call services
(hold slayer) without the gateway knowing their types.
"""
def __init__(
self,
settings: Settings,
sip_engine: Optional[SIPEngine] = None,
on_call_created=None,
on_call_ended=None,
):
self.settings = settings
self.event_bus = EventBus()
self.call_manager = CallManager(
self.event_bus,
on_call_created=on_call_created,
on_call_ended=on_call_ended,
)
self.media_pipeline = MediaPipeline(sample_rate=16000)
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
# Attached by the composition root (attach_services)
self._tts = None
# Per-call-mode launchers registered by the composition root
self._mode_handlers: dict[CallMode, Callable] = {}
# Device registry
self._devices: dict[str, Device] = {}
# Background tasks (per-call services, receptionist sessions) —
# tracked so shutdown can cancel them and GC can't drop them
self._tasks: set[asyncio.Task] = set()
# Startup time
self._started_at: Optional[datetime] = None
def spawn(self, coro, name: str) -> asyncio.Task:
"""Launch a tracked background task."""
task = asyncio.get_running_loop().create_task(coro, name=name)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
def attach_services(self, tts=None) -> None:
"""Attach shared services the gateway must manage on shutdown."""
self._tts = tts
def register_mode_handler(self, mode: CallMode, handler: Callable) -> None:
"""Register a launcher called as handler(call, sip_leg_id, call_flow_id)."""
self._mode_handlers[mode] = handler
# ================================================================
# Lifecycle
# ================================================================
async def start(self) -> None:
"""Boot the gateway — start media pipeline and SIP engine."""
logger.info("🔥 Starting AI PSTN Gateway...")
# Start media pipeline first so SIP engine can hand it RTP streams
await self.media_pipeline.start()
# Start SIP engine
await self.sip_engine.start()
logger.info(" SIP Engine: ready")
self._started_at = datetime.now()
trunk_status = await self.sip_engine.get_trunk_status()
trunk_registered = trunk_status.get("registered", False)
logger.info(f" SIP Trunk: {'registered' if trunk_registered else 'not registered'}")
logger.info(f" Devices: {len(self._devices)} registered")
logger.info("\U0001f525 AI PSTN Gateway is LIVE")
# Publish trunk registration status so dashboards/WS clients know immediately
if trunk_registered:
await self.event_bus.publish(GatewayEvent(
type=EventType.SIP_TRUNK_REGISTERED,
message=f"SIP trunk registered with {trunk_status.get('host')}",
data=trunk_status,
))
else:
reason = trunk_status.get("reason", "Trunk registration failed or not configured")
await self.event_bus.publish(GatewayEvent(
type=EventType.SIP_TRUNK_REGISTRATION_FAILED,
message=f"SIP trunk not registered — {reason}",
data=trunk_status,
))
async def stop(self) -> None:
"""Gracefully shut down."""
logger.info("Shutting down AI PSTN Gateway...")
# Cancel per-call background tasks before tearing down their deps
for task in list(self._tasks):
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
# End all active calls
for call_id in list(self.call_manager.active_calls.keys()):
call = self.call_manager.get_call(call_id)
if call:
await self.call_manager.end_call(call_id, CallStatus.CANCELLED)
# Stop SIP engine
await self.sip_engine.stop()
# Stop media pipeline last (after SIP no longer references streams)
try:
await self.media_pipeline.stop()
except Exception as e:
logger.error(f"Media pipeline stop error: {e}")
if self._tts is not None:
try:
await self._tts.close()
except Exception:
pass
self._started_at = None
logger.info("Gateway shut down cleanly.")
@property
def uptime(self) -> Optional[int]:
"""Gateway uptime in seconds."""
if self._started_at:
return int((datetime.now() - self._started_at).total_seconds())
return None
# ================================================================
# Call Operations
# ================================================================
async def make_call(
self,
number: str,
mode: CallMode = CallMode.DIRECT,
intent: Optional[str] = None,
call_flow_id: Optional[str] = None,
device: Optional[str] = None,
services: Optional[list[str]] = None,
) -> ActiveCall:
"""
Place an outbound call.
This is the main entry point for all call types:
- direct: Call and connect to device immediately
- hold_slayer: Navigate IVR, wait on hold, transfer when human detected
- ai_assisted: Connect with transcription, recording, noise cancel
"""
if is_emergency_number(number):
raise ValueError(
f"Refusing to dial emergency number '{number}'. Emergency calls "
"must be placed from a phone with E911 location service, not "
"through the gateway API."
)
active = len(self.call_manager.active_calls)
if active >= self.settings.max_concurrent_calls:
raise ValueError(
f"Concurrent-call limit reached ({active}/{self.settings.max_concurrent_calls}). "
"End an active call or raise MAX_CONCURRENT_CALLS."
)
# Create call in manager
call = await self.call_manager.create_call(
remote_number=number,
mode=mode,
intent=intent,
call_flow_id=call_flow_id,
device=device or self.settings.hold_slayer.default_transfer_device,
services=services,
)
# Place outbound call via SIP engine
try:
sip_leg_id = await self.sip_engine.make_call(
number=number,
caller_id=self.settings.sip_trunk.did,
)
self.call_manager.map_leg(sip_leg_id, call.id)
await self.call_manager.update_status(call.id, CallStatus.RINGING)
except Exception as e:
logger.error(f"Failed to place call: {e}")
await self.call_manager.update_status(call.id, CallStatus.FAILED)
raise
# Hand off to the registered per-mode launcher (e.g. hold slayer)
handler = self._mode_handlers.get(mode)
if handler is not None:
handler(call, sip_leg_id, call_flow_id)
return call
async def transfer_call(self, call_id: str, device_id: str) -> None:
"""Transfer an active call to a device."""
call = self.call_manager.get_call(call_id)
if not call:
raise ValueError(f"Call {call_id} not found")
device = self._devices.get(device_id)
if not device:
raise ValueError(f"Device {device_id} not found")
await self.call_manager.update_status(call_id, CallStatus.TRANSFERRING)
# Place call to device
device_leg_id = await self.sip_engine.call_device(device)
self.call_manager.map_leg(device_leg_id, call_id)
# Get the original PSTN leg
pstn_leg_id = next(
(
leg_id
for leg_id in self.call_manager.legs_for_call(call_id)
if leg_id != device_leg_id
),
None,
)
if pstn_leg_id:
# Bridge the PSTN leg and device leg
await self.sip_engine.bridge_calls(pstn_leg_id, device_leg_id)
await self.call_manager.update_status(call_id, CallStatus.BRIDGED)
else:
logger.error(f"Could not find PSTN leg for call {call_id}")
await self.call_manager.update_status(call_id, CallStatus.FAILED)
async def hangup_call(self, call_id: str) -> None:
"""Hang up a call."""
call = self.call_manager.get_call(call_id)
if not call:
raise ValueError(f"Call {call_id} not found")
# Hang up all legs associated with this call
for leg_id in self.call_manager.legs_for_call(call_id):
await self.sip_engine.hangup(leg_id)
await self.call_manager.end_call(call_id)
def get_call(self, call_id: str) -> Optional[ActiveCall]:
"""Get an active call."""
return self.call_manager.get_call(call_id)
# ================================================================
# Device Management
# ================================================================
def register_device(self, device: Device) -> None:
"""Register a device with the gateway, auto-assigning an extension."""
# Auto-assign a 2XX extension if not already set
if device.extension is None:
used = {
d.extension
for d in self._devices.values()
if d.extension is not None
}
device.extension = next_extension(used)
# Build a sip_uri from the extension if not provided
if device.sip_uri is None and device.extension is not None:
domain = self.settings.gateway_sip.domain
device.sip_uri = f"sip:{device.extension}@{domain}"
self._devices[device.id] = device
logger.info(
f"📱 Device registered: {device.name} "
f"ext={device.extension} uri={device.sip_uri}"
)
def unregister_device(self, device_id: str) -> None:
"""Unregister a device."""
device = self._devices.pop(device_id, None)
if device:
logger.info(f"📱 Device unregistered: {device.name}")
async def _on_sip_leg_state(self, leg_id: str, state: str) -> None:
"""
SIP leg state change from the engine (already on the loop).
Maps leg transitions onto call status. Status only moves
forward from the dialing phase — hold-slayer/receptionist
states (ON_HOLD, NAVIGATING_IVR, …) are never stomped by a
late ringing/connected signal from a second leg.
"""
call = self.call_manager.get_call_for_leg(leg_id)
if call is None:
return
if state == "ringing" and call.status == CallStatus.INITIATING:
await self.call_manager.update_status(call.id, CallStatus.RINGING)
elif state == "connected" and call.status in (
CallStatus.INITIATING,
CallStatus.RINGING,
):
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
elif state == "terminated":
self.call_manager.unmap_leg(leg_id)
# End the call only when its last leg is gone (a transfer
# keeps the call alive on the device leg)
if not self.call_manager.legs_for_call(call.id):
await self.call_manager.end_call(call.id)
async def _on_sip_device_registered(
self, aor: str, contact: str, expires: int
) -> None:
"""
Called by SippyEngine when a phone sends SIP REGISTER.
Finds or creates a Device entry and ensures it has an extension
and a sip_uri. Publishes a DEVICE_REGISTERED event on the bus.
"""
import uuid
# Look for an existing device with this AOR
existing = next(
(d for d in self._devices.values() if d.sip_uri == aor),
None,
)
if existing:
existing.is_online = expires > 0
existing.last_seen = datetime.now()
logger.info(
f"📱 Device refreshed: {existing.name} "
f"ext={existing.extension} expires={expires}"
)
if expires == 0:
await self.event_bus.publish(GatewayEvent(
type=EventType.DEVICE_OFFLINE,
message=f"{existing.name} (ext {existing.extension}) unregistered",
data={"device_id": existing.id, "aor": aor},
))
return
# New device — auto-register it
device_id = f"dev_{uuid.uuid4().hex[:8]}"
# Derive a friendly name from the AOR username (sip:alice@host → alice)
user_part = aor.split(":")[-1].split("@")[0] if ":" in aor else aor
dev = Device(
id=device_id,
name=user_part,
type="sip_phone",
sip_uri=aor,
is_online=True,
last_seen=datetime.now(),
)
self.register_device(dev) # assigns extension + sip_uri
await self.event_bus.publish(GatewayEvent(
type=EventType.DEVICE_REGISTERED,
message=(
f"{dev.name} registered as ext {dev.extension} "
f"({dev.sip_uri})"
),
data={
"device_id": dev.id,
"name": dev.name,
"extension": dev.extension,
"sip_uri": dev.sip_uri,
"contact": contact,
},
))
def preferred_device(self) -> Optional[Device]:
"""Get the highest-priority online device."""
online_devices = [
d for d in self._devices.values()
if d.can_receive_call
]
if online_devices:
return sorted(online_devices, key=lambda d: d.priority)[0]
# Fallback: any device that can receive calls (e.g., cell phone)
fallback = [
d for d in self._devices.values()
if d.type == DeviceType.CELL and d.phone_number
]
return sorted(fallback, key=lambda d: d.priority)[0] if fallback else None
@property
def devices(self) -> dict[str, Device]:
"""All registered devices."""
return dict(self._devices)
# ================================================================
# Status
# ================================================================
async def status(self) -> dict:
"""Full gateway status."""
trunk = await self.sip_engine.get_trunk_status()
return {
"uptime": self.uptime,
"trunk": trunk,
"devices": {d.id: {"name": d.name, "online": d.is_online} for d in self._devices.values()},
"active_calls": self.call_manager.active_call_count,
"event_subscribers": self.event_bus.subscriber_count,
}