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:
258
services/routing.py
Normal file
258
services/routing.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Routing Service — Smart routing for inbound calls.
|
||||
|
||||
Evaluates `RoutingRule` records against an incoming call's caller ID,
|
||||
DNIS, and the current time, returning a `RoutingDecision`. Also exposes
|
||||
a ring-chain helper that tries devices in priority order until one
|
||||
answers (or all timeouts elapse).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, time
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db.database import RoutingRuleRecord, get_session_factory
|
||||
from models.routing import (
|
||||
RoutingAction,
|
||||
RoutingActionType,
|
||||
RoutingDecision,
|
||||
RoutingMatch,
|
||||
RoutingRule,
|
||||
RoutingRuleCreate,
|
||||
RoutingRuleUpdate,
|
||||
TimeRange,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_hhmm(s: str) -> time:
|
||||
h, m = s.split(":")
|
||||
return time(hour=int(h), minute=int(m))
|
||||
|
||||
|
||||
def _time_in_range(now: datetime, tr: TimeRange) -> bool:
|
||||
"""True if `now` (interpreted in tr.tz) falls inside the window."""
|
||||
try:
|
||||
tz = ZoneInfo(tr.tz)
|
||||
except Exception:
|
||||
tz = ZoneInfo("UTC")
|
||||
|
||||
local = now.astimezone(tz)
|
||||
if local.weekday() not in tr.days:
|
||||
return False
|
||||
|
||||
start = _parse_hhmm(tr.start)
|
||||
end = _parse_hhmm(tr.end)
|
||||
cur = local.time()
|
||||
|
||||
if start <= end:
|
||||
return start <= cur < end
|
||||
# Wraps past midnight (e.g. 22:00 → 06:00)
|
||||
return cur >= start or cur < end
|
||||
|
||||
|
||||
def _caller_matches(pattern: Optional[str], caller_number: str) -> bool:
|
||||
if not pattern:
|
||||
return True
|
||||
return fnmatch.fnmatch(caller_number or "", pattern)
|
||||
|
||||
|
||||
def _dnis_matches(rule_dnis: Optional[str], dnis: str) -> bool:
|
||||
if not rule_dnis:
|
||||
return True
|
||||
return rule_dnis == dnis
|
||||
|
||||
|
||||
class RoutingService:
|
||||
"""Caches enabled rules and evaluates them per inbound call."""
|
||||
|
||||
DEFAULT_ACTION = RoutingAction(type=RoutingActionType.TAKE_MESSAGE)
|
||||
|
||||
def __init__(self, gateway):
|
||||
self.gateway = gateway
|
||||
self._rules: list[RoutingRule] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
await self.reload()
|
||||
|
||||
async def reload(self) -> None:
|
||||
async with self._lock:
|
||||
self._rules = await self._load_rules_from_db()
|
||||
logger.info(f"📋 Loaded {len(self._rules)} routing rules")
|
||||
|
||||
@property
|
||||
def rules(self) -> list[RoutingRule]:
|
||||
return list(self._rules)
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
caller_number: str,
|
||||
dnis: str,
|
||||
now: Optional[datetime] = None,
|
||||
) -> RoutingDecision:
|
||||
"""Walk rules in priority order, return first match or default."""
|
||||
now = now or datetime.now().astimezone()
|
||||
for rule in sorted(self._rules, key=lambda r: (r.priority, r.id)):
|
||||
if not rule.enabled:
|
||||
continue
|
||||
m = rule.match
|
||||
if not _caller_matches(m.caller_pattern, caller_number):
|
||||
continue
|
||||
if not _dnis_matches(m.dnis, dnis):
|
||||
continue
|
||||
if m.time_range and not _time_in_range(now, m.time_range):
|
||||
continue
|
||||
return RoutingDecision(
|
||||
matched_rule_id=rule.id,
|
||||
matched_rule_name=rule.name,
|
||||
action=rule.action,
|
||||
reason=f"matched rule '{rule.name}'",
|
||||
)
|
||||
return RoutingDecision(
|
||||
action=self.DEFAULT_ACTION,
|
||||
reason="no rule matched — default take_message",
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Ring chain
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def ring_chain(
|
||||
self,
|
||||
call_id: str,
|
||||
device_ids: list[str],
|
||||
ring_timeout: int = 25,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Try each device sequentially. Returns the device_id that answered,
|
||||
or None if all timed out.
|
||||
"""
|
||||
for device_id in device_ids:
|
||||
device = self.gateway.devices.get(device_id)
|
||||
if not device:
|
||||
continue
|
||||
if getattr(device, "dnd", False):
|
||||
logger.info(f"📞 Skipping {device_id} (DND)")
|
||||
continue
|
||||
try:
|
||||
await self.gateway.transfer_call(call_id, device_id)
|
||||
# If transfer_call returned normally, treat as picked up.
|
||||
return device_id
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(f"📞 Ring timeout for device {device_id}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"📞 Ring failed for device {device_id}: {e}")
|
||||
continue
|
||||
return None
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# CRUD
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def create_rule(self, payload: RoutingRuleCreate) -> RoutingRule:
|
||||
rule_id = f"rule_{uuid.uuid4().hex[:8]}"
|
||||
record = RoutingRuleRecord(
|
||||
id=rule_id,
|
||||
name=payload.name,
|
||||
priority=payload.priority,
|
||||
enabled=payload.enabled,
|
||||
match=payload.match.model_dump(),
|
||||
action=payload.action.model_dump(),
|
||||
)
|
||||
async with get_session_factory()() as session:
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
rule = RoutingRule(
|
||||
id=rule_id,
|
||||
name=payload.name,
|
||||
priority=payload.priority,
|
||||
enabled=payload.enabled,
|
||||
match=payload.match,
|
||||
action=payload.action,
|
||||
)
|
||||
async with self._lock:
|
||||
self._rules.append(rule)
|
||||
return rule
|
||||
|
||||
async def update_rule(
|
||||
self, rule_id: str, payload: RoutingRuleUpdate
|
||||
) -> Optional[RoutingRule]:
|
||||
async with get_session_factory()() as session:
|
||||
result = await session.execute(
|
||||
select(RoutingRuleRecord).where(RoutingRuleRecord.id == rule_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return None
|
||||
if payload.name is not None:
|
||||
record.name = payload.name
|
||||
if payload.priority is not None:
|
||||
record.priority = payload.priority
|
||||
if payload.enabled is not None:
|
||||
record.enabled = payload.enabled
|
||||
if payload.match is not None:
|
||||
record.match = payload.match.model_dump()
|
||||
if payload.action is not None:
|
||||
record.action = payload.action.model_dump()
|
||||
await session.commit()
|
||||
|
||||
await self.reload()
|
||||
return next((r for r in self._rules if r.id == rule_id), None)
|
||||
|
||||
async def delete_rule(self, rule_id: str) -> bool:
|
||||
async with get_session_factory()() as session:
|
||||
result = await session.execute(
|
||||
select(RoutingRuleRecord).where(RoutingRuleRecord.id == rule_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
return False
|
||||
await session.delete(record)
|
||||
await session.commit()
|
||||
async with self._lock:
|
||||
self._rules = [r for r in self._rules if r.id != rule_id]
|
||||
return True
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Internals
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def _load_rules_from_db(self) -> list[RoutingRule]:
|
||||
try:
|
||||
async with get_session_factory()() as session:
|
||||
result = await session.execute(select(RoutingRuleRecord))
|
||||
rows = result.scalars().all()
|
||||
except Exception as e:
|
||||
logger.warning(f"Routing rules load failed: {e}")
|
||||
return []
|
||||
|
||||
rules: list[RoutingRule] = []
|
||||
for row in rows:
|
||||
try:
|
||||
match = RoutingMatch(**(row.match or {}))
|
||||
action = RoutingAction(**(row.action or {}))
|
||||
rules.append(
|
||||
RoutingRule(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
priority=row.priority,
|
||||
enabled=row.enabled,
|
||||
match=match,
|
||||
action=action,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping malformed rule {row.id}: {e}")
|
||||
return rules
|
||||
Reference in New Issue
Block a user