""" 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 appropriate SIP engine from config.""" trunk = settings.sip_trunk gw_sip = settings.gateway_sip if trunk.host and trunk.host != "sip.provider.com": # Real trunk configured — use Sippy B2BUA try: 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, ) except Exception as e: logger.warning(f"Could not create SippyEngine: {e} — using mock") return MockSIPEngine() 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, }