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>
474 lines
17 KiB
Python
474 lines
17 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."
|
|
)
|
|
|
|
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_ended=None,
|
|
):
|
|
self.settings = settings
|
|
self.event_bus = EventBus()
|
|
self.call_manager = CallManager(self.event_bus, 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,
|
|
}
|