""" 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