Files
hold-slayer/services/notification.py
Robert Helewka 4048ce1db6 Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get
the mock engine; an unconfigured trunk fails startup with guidance
instead of silently degrading. Root-caused why the engine always ran
mock: nested pydantic-settings never read .env (no env_file on the
sub-settings classes) — all 8 now declare it.

/health stops lying: reports engine mode (sippy|mock), a live DB
SELECT 1, trunk registration state with reason, and TTS/STT
availability from their last real request; "healthy" now requires
ready + db + sippy + registered trunk.

Error policy: leaf services (tts/transcription/llm_client) raise and
track availability; call-loop callers catch, publish EventType.ERROR
naming the failed service, and apply an explicit fallback. Persistence
writes get one bounded 3x exponential retry, then an ERROR log — no
more silent data loss.

Event bus: a full subscriber queue drops its oldest event (counted)
instead of silently evicting the subscription; subscribe(replay_last=N)
delivers the advertised history replay, used by /ws/events (25).

Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM;
voicemail polls for early hangup and stops/transcribes/hangs up in
finally; RecordingSession finally keeps its leg_ids so taps detach.

Dead code removed: models/contact.py + Contact table, dtmf_buffer,
transcribe_stream stub, SMS stub in notification.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:01:45 -04:00

216 lines
7.1 KiB
Python

"""
Notification Service — Tell the user what's happening.
Sends notifications when:
- A human picks up (TRANSFER NOW!)
- Hold time estimates change
- Call fails or times out
- IVR navigation milestones
Supports multiple channels: WebSocket (always), SMS (optional),
push notifications (future).
"""
import asyncio
import logging
from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel
from config import Settings
from core.event_bus import EventBus
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
class NotificationChannel(str, Enum):
"""Where to send notifications."""
WEBSOCKET = "websocket"
SMS = "sms"
PUSH = "push"
class NotificationPriority(str, Enum):
"""How urgently to deliver."""
LOW = "low" # Status updates, hold time estimates
NORMAL = "normal" # IVR navigation milestones
HIGH = "high" # Human detected, call failed
CRITICAL = "critical" # Transfer happening NOW
class Notification(BaseModel):
"""A notification to send to the user."""
channel: NotificationChannel
priority: NotificationPriority
title: str
message: str
call_id: Optional[str] = None
data: dict[str, Any] = {}
timestamp: datetime = datetime.now()
class NotificationService:
"""
Sends notifications to users about call events.
Listens to the EventBus and routes events to the
appropriate notification channels.
"""
def __init__(self, event_bus: EventBus, settings: Settings):
self._event_bus = event_bus
self._settings = settings
self._task: Optional[asyncio.Task] = None
# Track what we've already notified (avoid spam)
self._notified: dict[str, set[str]] = {} # call_id -> set of event types
async def start(self) -> None:
"""Start listening for events to notify on."""
self._task = asyncio.create_task(self._listen_loop())
logger.info("📢 Notification service started")
async def stop(self) -> None:
"""Stop the notification listener."""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("📢 Notification service stopped")
async def _listen_loop(self) -> None:
"""Main event listener loop."""
subscription = self._event_bus.subscribe()
try:
async for event in subscription:
try:
await self._handle_event(event)
except Exception as e:
logger.error(f"Notification handler error: {e}", exc_info=True)
except asyncio.CancelledError:
pass
finally:
subscription.close()
async def _handle_event(self, event: GatewayEvent) -> None:
"""Route an event to the appropriate notification(s)."""
call_id = event.call_id or ""
# Initialize tracking for this call
if call_id and call_id not in self._notified:
self._notified[call_id] = set()
# Skip duplicate notifications
dedup_key = f"{event.type.value}:{event.data.get('step_id', '')}"
if call_id and dedup_key in self._notified.get(call_id, set()):
return
notification = self._event_to_notification(event)
if not notification:
return
# Mark as notified
if call_id:
self._notified[call_id].add(dedup_key)
# Send via all appropriate channels
await self._send(notification)
def _event_to_notification(self, event: GatewayEvent) -> Optional[Notification]:
"""Convert a gateway event to a notification (or None to skip)."""
if event.type == EventType.HUMAN_DETECTED:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.CRITICAL,
title="🚨 Human Detected!",
message="A live person picked up — transferring you now!",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.TRANSFER_STARTED:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.CRITICAL,
title="📞 Call Transferred",
message="Your call has been connected to the agent. Pick up your phone!",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.CALL_FAILED:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.HIGH,
title="❌ Call Failed",
message=event.message or "The call couldn't be completed.",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.HOLD_DETECTED:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.NORMAL,
title="⏳ On Hold",
message="You're on hold. We'll notify you when someone picks up.",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.IVR_STEP:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.LOW,
title="📍 IVR Navigation",
message=event.message or "Navigating phone menu...",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.IVR_DTMF_SENT:
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.LOW,
title="📱 Button Pressed",
message=event.message or f"Pressed {event.data.get('digits', '?')}",
call_id=event.call_id,
data=event.data,
)
elif event.type == EventType.CALL_ENDED:
# Clean up tracking
if event.call_id and event.call_id in self._notified:
del self._notified[event.call_id]
return Notification(
channel=NotificationChannel.WEBSOCKET,
priority=NotificationPriority.NORMAL,
title="📴 Call Ended",
message=event.message or "The call has ended.",
call_id=event.call_id,
data=event.data,
)
# Skip other event types (transcription, classification, etc.)
return None
async def _send(self, notification: Notification) -> None:
"""Send a notification via the appropriate channel."""
logger.info(
f"📢 [{notification.priority.value}] {notification.title}: "
f"{notification.message}"
)
# WebSocket notifications go through the event bus
# (the WebSocket handler in the API reads from EventBus directly)