feat: add call history API endpoints and TTS service client

Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
This commit is contained in:
2026-05-22 06:28:33 -04:00
parent dbdb03beb9
commit 63f1a270bb
28 changed files with 2275 additions and 11 deletions

View File

@@ -168,6 +168,7 @@ class SippyEngine(SIPEngine):
media_pipeline=None, # MediaPipeline instance
on_leg_state_change: Optional[Callable] = None,
on_device_registered: Optional[Callable] = None,
on_incoming_call: Optional[Callable] = None,
):
# SIP config
self._sip_address = sip_address
@@ -186,6 +187,7 @@ class SippyEngine(SIPEngine):
# Callbacks for async state changes
self._on_leg_state_change = on_leg_state_change
self._on_device_registered = on_device_registered
self._on_incoming_call = on_incoming_call
self._loop: Optional[asyncio.AbstractEventLoop] = None
# State
@@ -355,21 +357,54 @@ class SippyEngine(SIPEngine):
await self._on_device_registered(aor, contact, expires)
def _handle_incoming_invite(self, req, sip_t):
"""Handle an incoming INVITE — create inbound call leg."""
"""Handle an incoming INVITE — create inbound call leg.
The gateway is notified via `on_incoming_call`; it decides
whether to answer (via `accept_inbound`) or reject the leg
based on routing rules.
"""
from_uri = str(req.getHFBody("from").getUri())
to_uri = str(req.getHFBody("to").getUri())
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
leg = SipCallLeg(leg_id, "inbound", from_uri)
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
leg.pending_invite = req
self._legs[leg_id] = leg
logger.info(f" Incoming call: {from_uri}{to_uri} (leg: {leg_id})")
# Auto-answer for now (gateway always answers)
# In production, this would check routing rules
# Surface to the gateway. If no callback is wired, fall back to
# auto-answer so we don't regress the previous behavior.
if self._on_incoming_call and self._loop:
asyncio.run_coroutine_threadsafe(
self._on_incoming_call(from_uri, to_uri, leg_id),
self._loop,
)
else:
controller = SippyCallController(leg, self)
controller.on_connected(str(req.getBody()) if req.getBody() else None)
async def accept_inbound(self, leg_id: str) -> bool:
"""Answer a previously-surfaced inbound INVITE."""
leg = self._legs.get(leg_id)
if not leg or leg.direction != "inbound":
return False
req = getattr(leg, "pending_invite", None)
controller = SippyCallController(leg, self)
controller.on_connected(str(req.getBody()) if req.getBody() else None)
body = str(req.getBody()) if req and req.getBody() else None
controller.on_connected(body)
return True
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
"""Reject a previously-surfaced inbound INVITE with a SIP error."""
leg = self._legs.pop(leg_id, None)
if not leg or leg.direction != "inbound":
return False
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
# Real SIP rejection would go through Sippy here; we just drop the leg
# in stub mode so callers see the call terminate.
return True
def _handle_incoming_bye(self, req, sip_t):
"""Handle incoming BYE — tear down call leg."""