The gateway was the composition root, device registry, inbound-call policy, and call-operations service in one class, with core↔services circular imports papered over by function-local imports, wiring done by assigning private attributes, and MCP tools duplicating REST query logic against their own sessions. Composition: - main.py's lifespan now builds every service and wires them by constructor/registration. gateway.from_config() is gone; core/ no longer imports services/ anywhere — the cycle is dead. - Inbound-call policy moved to ReceptionistService.on_inbound_call (routing evaluation, reject/answer, screening dispatch); wired as the engine's on_incoming_call by the lifespan. Receptionist deps (tts/transcription/recording/routing) are constructor-injected — no more gateway._tts reach-through or importing hold_slayer's private _get_llm (now services.llm_client.get_llm, shared). - Hold-slayer launch goes through a mode-handler registry (register_mode_handler); the gateway no longer knows the service's type. CallManager takes on_call_ended in its constructor. - build_sip_engine() is a pure function taking explicit callbacks. - api/routing.py uses the routing service from app.state via a proper dependency instead of gateway._routing. Shared data layer: - db.session_scope() is the one session convention (get_db wraps it). - services/call_persistence.py gains the query/write functions and the single StoredCallFlow→CallFlow mapper; api/call_flows.py, api/call_history.py, and the six DB-touching MCP tools are thin wrappers over them — the two surfaces can't drift. - legs_for_call() replaces the three private _call_legs scans (gateway transfer/hangup, REST dtmf, MCP dtmf). 7 new tests (mode-handler launch, on_call_ended hook, receptionist inbound answer/reject, call-flow CRUD round-trip and history routes against real SQLite through the shared layer). aiosqlite added to dev deps for that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
214 lines
7.5 KiB
Python
214 lines
7.5 KiB
Python
"""
|
|
Call Manager — Active call state tracking and event bus.
|
|
|
|
Central nervous system of the gateway. Tracks all active calls,
|
|
publishes events, and coordinates between SIP engine and services.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from core.event_bus import EventBus, EventSubscription
|
|
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult
|
|
from models.events import EventType, GatewayEvent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CallManager:
|
|
"""
|
|
Manages all active calls and their state.
|
|
|
|
The single source of truth for what's happening on the gateway.
|
|
"""
|
|
|
|
def __init__(self, event_bus: EventBus, on_call_ended=None):
|
|
self.event_bus = event_bus
|
|
self._active_calls: dict[str, ActiveCall] = {}
|
|
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
|
|
self._on_call_ended = on_call_ended # async callback(call, final_status)
|
|
|
|
# ================================================================
|
|
# Call Lifecycle
|
|
# ================================================================
|
|
|
|
async def create_call(
|
|
self,
|
|
remote_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:
|
|
"""Create a new call and track it."""
|
|
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
|
|
|
call = ActiveCall(
|
|
id=call_id,
|
|
remote_number=remote_number,
|
|
mode=mode,
|
|
intent=intent,
|
|
call_flow_id=call_flow_id,
|
|
device=device,
|
|
services=services or [],
|
|
)
|
|
|
|
self._active_calls[call_id] = call
|
|
|
|
await self.event_bus.publish(GatewayEvent(
|
|
type=EventType.CALL_INITIATED,
|
|
call_id=call_id,
|
|
data={"number": remote_number, "mode": mode.value, "intent": intent},
|
|
message=f"📞 Calling {remote_number} ({mode.value})",
|
|
))
|
|
|
|
return call
|
|
|
|
async def update_status(self, call_id: str, status: CallStatus) -> None:
|
|
"""Update a call's status and publish event."""
|
|
call = self._active_calls.get(call_id)
|
|
if not call:
|
|
logger.warning(f"Cannot update status: call {call_id} not found")
|
|
return
|
|
|
|
old_status = call.status
|
|
call.status = status
|
|
|
|
# Track timing milestones
|
|
if status == CallStatus.CONNECTED and not call.connected_at:
|
|
call.connected_at = datetime.now()
|
|
elif status == CallStatus.ON_HOLD:
|
|
call.hold_started_at = datetime.now()
|
|
elif status == CallStatus.HUMAN_DETECTED:
|
|
call.hold_started_at = None # Stop counting hold time
|
|
|
|
# Map status to event type
|
|
event_map = {
|
|
CallStatus.RINGING: EventType.CALL_RINGING,
|
|
CallStatus.CONNECTED: EventType.CALL_CONNECTED,
|
|
CallStatus.NAVIGATING_IVR: EventType.IVR_STEP,
|
|
CallStatus.ON_HOLD: EventType.HOLD_DETECTED,
|
|
CallStatus.HUMAN_DETECTED: EventType.HUMAN_DETECTED,
|
|
CallStatus.TRANSFERRING: EventType.TRANSFER_STARTED,
|
|
CallStatus.BRIDGED: EventType.TRANSFER_COMPLETE,
|
|
CallStatus.COMPLETED: EventType.CALL_ENDED,
|
|
CallStatus.FAILED: EventType.CALL_FAILED,
|
|
}
|
|
|
|
event_type = event_map.get(status, EventType.CALL_CONNECTED)
|
|
|
|
await self.event_bus.publish(GatewayEvent(
|
|
type=event_type,
|
|
call_id=call_id,
|
|
data={
|
|
"old_status": old_status.value,
|
|
"new_status": status.value,
|
|
"duration": call.duration,
|
|
"hold_time": call.hold_time,
|
|
},
|
|
message=f"Call {call_id}: {old_status.value} → {status.value}",
|
|
))
|
|
|
|
async def add_classification(
|
|
self, call_id: str, result: ClassificationResult
|
|
) -> None:
|
|
"""Add an audio classification result to a call."""
|
|
call = self._active_calls.get(call_id)
|
|
if not call:
|
|
return
|
|
|
|
call.current_classification = result.audio_type
|
|
call.classification_history.append(result)
|
|
|
|
await self.event_bus.publish(GatewayEvent(
|
|
type=EventType.AUDIO_CLASSIFIED,
|
|
call_id=call_id,
|
|
data={
|
|
"audio_type": result.audio_type.value,
|
|
"confidence": result.confidence,
|
|
},
|
|
message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
|
|
))
|
|
|
|
async def add_transcript(self, call_id: str, text: str) -> None:
|
|
"""Add a transcript chunk to a call."""
|
|
call = self._active_calls.get(call_id)
|
|
if not call:
|
|
return
|
|
|
|
call.transcript_chunks.append(text)
|
|
|
|
await self.event_bus.publish(GatewayEvent(
|
|
type=EventType.TRANSCRIPT_CHUNK,
|
|
call_id=call_id,
|
|
data={"text": text},
|
|
message=f"📝 '{text[:80]}...' " if len(text) > 80 else f"📝 '{text}'",
|
|
))
|
|
|
|
async def end_call(self, call_id: str, status: CallStatus = CallStatus.COMPLETED) -> Optional[ActiveCall]:
|
|
"""End a call and remove from active tracking."""
|
|
call = self._active_calls.pop(call_id, None)
|
|
if call:
|
|
call.status = status
|
|
await self.event_bus.publish(GatewayEvent(
|
|
type=EventType.CALL_ENDED,
|
|
call_id=call_id,
|
|
data={
|
|
"duration": call.duration,
|
|
"hold_time": call.hold_time,
|
|
"final_status": status.value,
|
|
},
|
|
message=f"📵 Call ended: {call.remote_number} ({call.duration}s, hold: {call.hold_time}s)",
|
|
))
|
|
if self._on_call_ended is not None:
|
|
try:
|
|
await self._on_call_ended(call, status)
|
|
except Exception as e:
|
|
logger.warning(f"on_call_ended hook failed for {call_id}: {e}")
|
|
return call
|
|
|
|
# ================================================================
|
|
# Leg Mapping
|
|
# ================================================================
|
|
|
|
def map_leg(self, sip_leg_id: str, call_id: str) -> None:
|
|
"""Map a SIP leg ID to a call ID."""
|
|
self._call_legs[sip_leg_id] = call_id
|
|
|
|
def unmap_leg(self, sip_leg_id: str) -> None:
|
|
"""Remove a SIP leg mapping (leg terminated)."""
|
|
self._call_legs.pop(sip_leg_id, None)
|
|
|
|
def legs_for_call(self, call_id: str) -> list[str]:
|
|
"""All SIP leg IDs currently mapped to a call."""
|
|
return [leg for leg, cid in self._call_legs.items() if cid == call_id]
|
|
|
|
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
|
|
"""Look up which call a SIP leg belongs to."""
|
|
call_id = self._call_legs.get(sip_leg_id)
|
|
if call_id:
|
|
return self._active_calls.get(call_id)
|
|
return None
|
|
|
|
# ================================================================
|
|
# Queries
|
|
# ================================================================
|
|
|
|
def get_call(self, call_id: str) -> Optional[ActiveCall]:
|
|
"""Get an active call by ID."""
|
|
return self._active_calls.get(call_id)
|
|
|
|
@property
|
|
def active_calls(self) -> dict[str, ActiveCall]:
|
|
"""All active calls."""
|
|
return dict(self._active_calls)
|
|
|
|
@property
|
|
def active_call_count(self) -> int:
|
|
return len(self._active_calls)
|