"""Multi-channel notification system with priority routing and escalation. This module provides: - Notification model for representing alerts and messages - Notifier class for dispatching notifications to multiple channels - EscalationManager for handling quiet hours and escalation rules - Integration with event bus for automatic notification triggers The system supports multiple channels (dashboard, email, SMS, Home Assistant TTS) with priority-based routing and digest mode for batching low-priority alerts. """ from __future__ import annotations import json import logging import uuid from dataclasses import dataclass, field from datetime import UTC, datetime, time from pathlib import Path from typing import Literal from pydantic import BaseModel, Field from rex import email_service from rex.retry import RetryPolicy, retry_call logger = logging.getLogger(__name__) # Global instances _notifier: Notifier | None = None _escalation_manager: EscalationManager | None = None def get_email_service(): """Return the email service via the module namespace. Keeping this thin wrapper preserves existing test patch points while still resolving the factory dynamically from ``rex.email_service``. """ return email_service.get_email_service() def _utc_now() -> datetime: """Return the current UTC datetime.""" return datetime.now(UTC) # --- Models --- class NotificationRequest(BaseModel): """A notification request to be delivered to users. This differs from the Notification model in rex.contracts.core in that it supports multiple channel preferences and is specific to the notification service. """ id: str = Field( default_factory=lambda: f"notif_{uuid.uuid4().hex[:16]}", description="Unique notification identifier", ) priority: Literal["urgent", "normal", "digest"] = Field( default="normal", description="Priority level affecting delivery timing and channels", ) title: str = Field( ..., description="Notification title/subject", ) body: str = Field( ..., description="Notification body content", ) timestamp: datetime = Field( default_factory=_utc_now, description="When the notification was created (UTC)", ) channel_preferences: list[str] = Field( default_factory=lambda: ["dashboard"], description="Ordered list of preferred delivery channels", ) idempotency_key: str | None = Field( default=None, description="Optional idempotency key to prevent duplicate delivery.", ) action_url: str | None = Field( default=None, description="Optional URL or app route for the user to act on this notification.", ) action_label: str | None = Field( default=None, description="Human-readable label for the action button (e.g. 'Configure Voice').", ) metadata: dict = Field( default_factory=dict, description="Additional metadata for the notification", ) acknowledged_at: datetime | None = Field( default=None, description="When the notification was acknowledged (for escalation)", ) model_config = { "json_schema_extra": { "examples": [ { "id": "notif_abc123", "priority": "urgent", "title": "High Priority Email", "body": "You have a high importance email from your manager.", "timestamp": "2024-01-15T10:30:00Z", "channel_preferences": ["sms", "email", "dashboard"], } ] } } @dataclass class DigestQueue: """Queue for batching digest notifications.""" channel: str notifications: list[NotificationRequest] = field(default_factory=list) last_flush_at: datetime | None = None # --- Notifier --- class Notifier: """Multi-channel notification dispatcher. The Notifier routes notifications to appropriate channels based on priority and channel preferences. It supports: - Urgent: Send immediately to all preferred channels - Normal: Send to first available channel - Digest: Queue and send summary after configured interval """ def __init__( self, digest_interval_seconds: int = 3600, storage_path: Path | None = None, escalation_manager: EscalationManager | None = None, enforce_quiet_hours: bool = True, max_sent_log_entries: int = 2000, ): """Initialize the notifier. Args: digest_interval_seconds: How often to flush digest queues storage_path: Path to store digest queue data """ self.digest_interval = digest_interval_seconds self.storage_path = storage_path or Path("data/notifications") self.escalation_manager = escalation_manager self.enforce_quiet_hours = enforce_quiet_hours self.digest_file = self.storage_path / "digests.json" self.sent_log_file = self.storage_path / "sent_log.json" self.max_sent_log_entries = max_sent_log_entries self.digest_queues: dict[str, DigestQueue] = {} self._sent_log: dict[str, str] = {} self._load_digests() self._load_sent_log() def _load_digests(self) -> None: """Load digest queues from disk.""" self.storage_path.mkdir(parents=True, exist_ok=True) if self.digest_file.exists(): try: with open(self.digest_file) as f: data = json.load(f) for channel, queue_data in data.items(): notifications = [ NotificationRequest.model_validate(n) for n in queue_data.get("notifications", []) ] last_flush_str = queue_data.get("last_flush_at") last_flush = datetime.fromisoformat(last_flush_str) if last_flush_str else None self.digest_queues[channel] = DigestQueue( channel=channel, notifications=notifications, last_flush_at=last_flush, ) logger.info(f"Loaded {len(self.digest_queues)} digest queues from disk") except Exception as e: logger.warning(f"Failed to load digest queues: {e}") self.digest_queues = {} def _save_digests(self) -> None: """Save digest queues to disk.""" try: data = {} for channel, queue in self.digest_queues.items(): data[channel] = { "notifications": [n.model_dump(mode="json") for n in queue.notifications], "last_flush_at": ( queue.last_flush_at.isoformat() if queue.last_flush_at else None ), } with open(self.digest_file, "w") as f: json.dump(data, f, indent=2, default=str) except Exception as e: logger.error(f"Failed to save digest queues: {e}") def _load_sent_log(self) -> None: """Load sent notification idempotency log.""" if not self.sent_log_file.exists(): return try: with open(self.sent_log_file, encoding="utf-8") as f: data = json.load(f) self._sent_log = dict(data.get("sent", {}).items()) except Exception as e: logger.warning(f"Failed to load sent log: {e}") self._sent_log = {} def _save_sent_log(self) -> None: """Persist sent notification idempotency log.""" try: payload = {"sent": self._sent_log} with open(self.sent_log_file, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) except Exception as e: logger.warning(f"Failed to save sent log: {e}") def _prune_sent_log(self) -> None: """Prune the sent log to the most recent entries.""" if len(self._sent_log) <= self.max_sent_log_entries: return items = sorted(self._sent_log.items(), key=lambda item: item[1], reverse=True) trimmed = dict(items[: self.max_sent_log_entries]) self._sent_log = trimmed def _idempotency_key(self, notification: NotificationRequest) -> str: return notification.idempotency_key or notification.id def _mark_sent(self, notification: NotificationRequest) -> None: key = self._idempotency_key(notification) self._sent_log[key] = _utc_now().isoformat() self._prune_sent_log() self._save_sent_log() def _is_duplicate(self, notification: NotificationRequest) -> bool: key = self._idempotency_key(notification) return key in self._sent_log def send(self, notification: NotificationRequest) -> None: """Send a notification based on its priority. - Urgent: Send immediately to all preferred channels - Normal: Send to first available channel - Digest: Add to queue for later batch sending Args: notification: The notification to send """ logger.info(f"Sending {notification.priority} notification: {notification.title}") if self._is_duplicate(notification): logger.info( "Skipping duplicate notification %s (idempotency key %s)", notification.id, self._idempotency_key(notification), ) return if ( self.escalation_manager and self.enforce_quiet_hours and self.escalation_manager.should_suppress(notification) ): if notification.priority == "digest": logger.info( "Suppressing digest notification during quiet hours/DND; " "queuing for later delivery." ) self._queue_digest(notification) else: logger.info( "Suppressing notification during quiet hours/DND: %s", notification.title, ) return if notification.priority == "urgent": self._send_urgent(notification) self._mark_sent(notification) elif notification.priority == "normal": self._send_normal(notification) self._mark_sent(notification) elif notification.priority == "digest": self._queue_digest(notification) else: logger.warning(f"Unknown priority: {notification.priority}") return if self.escalation_manager and notification.priority == "urgent": next_channel = ( notification.channel_preferences[1] if len(notification.channel_preferences) > 1 else "email" ) self.escalation_manager.track_notification(notification, next_channel) def _send_urgent(self, notification: NotificationRequest) -> None: """Send urgent notification to all preferred channels immediately.""" for channel in notification.channel_preferences: try: self._dispatch_to_channel(channel, notification) except Exception as e: logger.error(f"Failed to send urgent notification to {channel}: {e}") def _send_normal(self, notification: NotificationRequest) -> None: """Send normal notification to first available channel.""" for channel in notification.channel_preferences: try: self._dispatch_to_channel(channel, notification) logger.debug( f"Sent normal notification to {channel}, " f"skipping remaining channels" ) break except Exception as e: logger.warning(f"Failed to send to {channel}, trying next channel: {e}") continue def _queue_digest(self, notification: NotificationRequest) -> None: """Add notification to digest queue.""" if self._is_duplicate(notification): logger.info( "Skipping duplicate digest notification %s (idempotency key %s)", notification.id, self._idempotency_key(notification), ) return for channel in notification.channel_preferences: if channel not in self.digest_queues: self.digest_queues[channel] = DigestQueue(channel=channel) self.digest_queues[channel].notifications.append(notification) logger.debug(f"Queued digest notification for {channel}") self._save_digests() self._mark_sent(notification) def _dispatch_to_channel(self, channel: str, notification: NotificationRequest) -> None: """Dispatch notification to a specific channel. Args: channel: The channel to send to notification: The notification to send Raises: Exception: If dispatch fails """ def _dispatch() -> None: if channel == "dashboard": self._send_to_dashboard(notification) elif channel == "email": self._send_to_email(notification) elif channel == "sms": self._send_to_sms(notification) elif channel == "ha_tts": self._send_to_ha_tts(notification) else: logger.warning(f"Unknown channel: {channel}") if channel == "dashboard": _dispatch() return retry_policy = RetryPolicy( attempts=3, initial_backoff_seconds=0.5, backoff_multiplier=2.0, max_backoff_seconds=4.0, retry_exceptions=(Exception,), ) retry_call( _dispatch, policy=retry_policy, on_retry=lambda attempt, exc, delay: logger.warning( "Notification dispatch retry %d/%d for %s in %.1fs: %s", attempt, retry_policy.attempts, channel, delay, exc, ), ) def send_to_channel(self, channel: str, notification: NotificationRequest) -> None: """Send a notification directly to a specific channel.""" self._dispatch_to_channel(channel, notification) def _send_to_dashboard(self, notification: NotificationRequest) -> None: """Log notification to the dashboard channel. The legacy dashboard notification store is pending retirement as part of the OpenClaw migration. Notifications are logged here; OpenClaw will handle persistent dashboard delivery once the migration is complete. """ # OPENCLAW-REPLACE: restore persistent storage here once the OpenClaw # dashboard adapter is wired in and the legacy store is retired. logger.info("[DASHBOARD] %s: %s", notification.title, notification.body) def _send_to_email(self, notification: NotificationRequest) -> None: """Send notification via email. Always dispatches through ``EmailService.send()`` so the real SMTP backend is used when configured. When no recipient or owner is provided in notification metadata the call is skipped with a warning — delivery never falls back to another user's account. On send failure the error is logged and re-raised so the caller can record the notification as failed rather than silently dropping it. Metadata keys: ``to_email`` — recipient address (required for delivery). ``email_user_id`` — owning user whose account sends the email (required for delivery; fail closed). ``email_account_id`` — route through a specific account owned by that user (optional). """ try: service = get_email_service() to_email = notification.metadata.get("to_email") account_id = notification.metadata.get("email_account_id") user_id = notification.metadata.get("email_user_id") if not to_email: logger.warning( "[EMAIL] No recipient address configured for notification %r; skipping", notification.title, ) return if not user_id: logger.warning( "[EMAIL] No owning user (email_user_id) for notification %r; " "skipping (email delivery requires an explicit owner)", notification.title, ) return result = service.send( to=to_email, subject=notification.title, body=notification.body, account_id=account_id, user_id=user_id, ) if result.get("ok"): logger.info( "[EMAIL] Sent notification %r to %s", notification.title, to_email, ) else: error = result.get("error", "unknown error") logger.error( "[EMAIL] Send failed for notification %r: %s", notification.title, error, ) raise RuntimeError(f"Email send failed: {error}") except RuntimeError: raise except Exception as exc: logger.error("[EMAIL] Notification delivery failed: %s", exc) raise def _send_to_sms(self, notification: NotificationRequest) -> None: """Send notification via SMS channel. # OPENCLAW-REPLACE: SMS delivery stub — pending migration to OpenClaw # messaging backend. Persistent delivery will be restored once the # OpenClaw SMS tool is wired into the notification routing path. """ logger.info( "[SMS] notification queued (stub): %s — to_number=%s", notification.title, notification.metadata.get("to_number"), ) def _send_to_ha_tts(self, notification: NotificationRequest) -> None: """Send notification to Home Assistant TTS. Calls the HA REST API when the channel is configured and enabled. Falls back to a logged stub when HA TTS is not configured or when the optional ``requests`` package is unavailable. Per-notification overrides via ``metadata``: - ``ha_entity_id``: target media player entity (overrides default) - ``ha_tts_domain``: TTS service domain (overrides default) - ``ha_tts_service``: TTS service name (overrides default) """ try: from rex.ha_tts.client import build_ha_tts_client client = build_ha_tts_client() except Exception as exc: logger.warning("[HA_TTS] Could not build HA TTS client: %s", exc) client = None if client is None: # Channel disabled or not configured — log stub and return silently. logger.info("[HA_TTS] Channel not configured; would announce: %s", notification.title) return message = ( f"{notification.title}: {notification.body}" if notification.body else notification.title ) entity_id = notification.metadata.get("ha_entity_id") or None # Honour per-notification TTS domain/service overrides. These values # control the REST API path in HaTtsClient.speak(), so apply them on the # client for this call only and restore immediately afterwards. tts_domain = notification.metadata.get("ha_tts_domain") tts_service = notification.metadata.get("ha_tts_service") original_domain = client.tts_domain original_service = client.tts_service try: if tts_domain: client.tts_domain = tts_domain if tts_service: client.tts_service = tts_service result = client.speak(message, entity_id=entity_id) finally: client.tts_domain = original_domain client.tts_service = original_service if not result.ok: raise RuntimeError(result.error or "HA TTS announcement failed") def flush_digests(self, channel: str | None = None) -> int: """Flush digest queues and send summaries. Args: channel: Specific channel to flush, or None for all channels Returns: Number of digest summaries sent """ channels_to_flush = [channel] if channel else list(self.digest_queues.keys()) count = 0 for ch in channels_to_flush: if ch not in self.digest_queues: continue queue = self.digest_queues[ch] if not queue.notifications: continue # Create digest summary — propagate to_email, email_account_id, # and the owning email_user_id from the first queued notification # that carries a recipient so the digest delivery goes through # EmailService as the correct owner. summary = self._create_digest_summary(queue.notifications) digest_meta: dict = {} for queued in queue.notifications: if queued.metadata.get("to_email"): digest_meta["to_email"] = queued.metadata["to_email"] if queued.metadata.get("email_account_id"): digest_meta["email_account_id"] = queued.metadata["email_account_id"] if queued.metadata.get("email_user_id"): digest_meta["email_user_id"] = queued.metadata["email_user_id"] break digest_notification = NotificationRequest( priority="normal", title=f"Digest Summary ({len(queue.notifications)} items)", body=summary, channel_preferences=[ch], metadata=digest_meta, ) # Send the digest — log failures but continue flushing other queues try: self._dispatch_to_channel(ch, digest_notification) except Exception as exc: logger.error("[DIGEST] Email delivery failed for channel %r: %s", ch, exc) # Clear queue and update flush time queue.notifications.clear() queue.last_flush_at = _utc_now() count += 1 self._save_digests() logger.info(f"Flushed {count} digest queue(s)") return count def _create_digest_summary(self, notifications: list[NotificationRequest]) -> str: """Create a summary of digest notifications.""" lines = ["Notification Digest:\n"] for i, notif in enumerate(notifications, 1): lines.append(f"{i}. {notif.title}") if notif.body: # Truncate long bodies body_preview = notif.body[:80] + "..." if len(notif.body) > 80 else notif.body lines.append(f" {body_preview}") return "\n".join(lines) def list_digests(self) -> dict[str, list[dict]]: """List all queued digest notifications. Returns: Dictionary mapping channel names to lists of notification dicts """ result = {} for channel, queue in self.digest_queues.items(): result[channel] = [ { "id": n.id, "title": n.title, "body": n.body, "timestamp": n.timestamp.isoformat(), } for n in queue.notifications ] return result def setup_event_subscriptions(self) -> None: """Subscribe to event bus events to create notifications automatically.""" try: from rex.openclaw.event_bridge import EventBridge bus = EventBridge() # Subscribe to user-scoped email and calendar events. The # shared "email.unread" / "calendar.update" topics carry only a # safe envelope (no private fields); full payloads are published # per owner on ".user.", so a wildcard # subscription with a prefix filter is used here (this is a # trusted system component, not a user surface). bus.subscribe("*", self._on_any_event) logger.info("Notification system subscribed to event bus events") except Exception as e: logger.warning(f"Failed to subscribe to events: {e}") def _on_any_event(self, event) -> None: """Route user-scoped email and calendar events to their handlers.""" event_type = getattr(event, "event_type", "") if not isinstance(event_type, str): return if event_type.startswith("email.unread.user."): self._on_email_unread(event) elif event_type.startswith("calendar.update.user."): self._on_calendar_update(event) def _on_email_unread(self, event) -> None: """Handle user-scoped email.unread events.""" try: payload = event.payload if hasattr(event, "payload") else event emails = payload.get("emails", []) or payload.get("messages", []) user_id = payload.get("user_id") # Check for high-importance emails for email in emails: importance = email.get("importance_score", 0.5) if importance >= 0.8: notification = NotificationRequest( priority="urgent", title="High Importance Email", body=f"From: {email.get('from_addr', 'Unknown')}\n" f"Subject: {email.get('subject', 'No subject')}", channel_preferences=["sms", "email", "dashboard"], metadata={"email_id": email.get("id"), "user_id": user_id}, ) self.send(notification) except Exception as e: logger.error(f"Error handling email.unread event: {e}") def _on_calendar_update(self, event) -> None: """Handle user-scoped calendar.update events.""" try: payload = event.payload if hasattr(event, "payload") else event events = payload.get("events", []) user_id = payload.get("user_id") # Notify about upcoming events (within 15 minutes) now = _utc_now() for cal_event in events: start_time_str = cal_event.get("start_time") if not start_time_str: continue start_time = datetime.fromisoformat(start_time_str) time_until = (start_time - now).total_seconds() if 0 < time_until <= 900: # Within 15 minutes notification = NotificationRequest( priority="normal", title="Upcoming Calendar Event", body=f"{cal_event.get('title', 'Event')} " f"starts at {start_time.strftime('%I:%M %p')}", channel_preferences=["dashboard", "ha_tts"], metadata={"event_id": cal_event.get("event_id"), "user_id": user_id}, ) self.send(notification) except Exception as e: logger.error(f"Error handling calendar.update event: {e}") # --- Escalation Manager --- @dataclass class EscalationRule: """Rule for escalating unacknowledged notifications.""" notification_id: str sent_at: datetime escalation_delay_minutes: int next_channel: str notification: NotificationRequest escalated: bool = False class EscalationManager: """Manages notification escalation and quiet hours. The EscalationManager: - Defines quiet hours when non-urgent notifications are suppressed - Tracks unacknowledged urgent notifications - Escalates to alternative channels if not acknowledged within threshold """ def __init__( self, quiet_hours_start: time = time(22, 0), quiet_hours_end: time = time(7, 0), escalation_delay_minutes: int = 5, ): """Initialize the escalation manager. Args: quiet_hours_start: Start of quiet hours (default 22:00) quiet_hours_end: End of quiet hours (default 07:00) escalation_delay_minutes: Minutes to wait before escalating """ self.quiet_hours_start = quiet_hours_start self.quiet_hours_end = quiet_hours_end self.escalation_delay = escalation_delay_minutes self.dnd_enabled = False self.pending_escalations: dict[str, EscalationRule] = {} def is_quiet_hours(self, dt: datetime | None = None) -> bool: """Check if the given time is within quiet hours. Args: dt: Datetime to check (defaults to now) Returns: True if within quiet hours """ if dt is None: dt = _utc_now() current_time = dt.time() # Handle quiet hours that span midnight if self.quiet_hours_start < self.quiet_hours_end: return self.quiet_hours_start <= current_time < self.quiet_hours_end else: return current_time >= self.quiet_hours_start or current_time < self.quiet_hours_end def should_suppress(self, notification: NotificationRequest) -> bool: """Check if a notification should be suppressed. Args: notification: The notification to check Returns: True if the notification should be suppressed """ # Urgent notifications always go through if notification.priority == "urgent": return False # DND mode blocks all non-urgent if self.dnd_enabled: return True # Quiet hours blocks normal and digest notifications if self.is_quiet_hours(): return True return False def track_notification( self, notification: NotificationRequest, next_channel: str = "email", ) -> None: """Track a notification for potential escalation. Args: notification: The notification to track next_channel: Channel to escalate to if not acknowledged """ if notification.priority != "urgent": return rule = EscalationRule( notification_id=notification.id, sent_at=notification.timestamp, escalation_delay_minutes=self.escalation_delay, next_channel=next_channel, notification=notification.model_copy(deep=True), ) self.pending_escalations[notification.id] = rule logger.debug(f"Tracking notification {notification.id} for escalation") def acknowledge(self, notification_id: str) -> bool: """Mark a notification as acknowledged. Args: notification_id: The notification to acknowledge Returns: True if the notification was found and acknowledged """ if notification_id in self.pending_escalations: del self.pending_escalations[notification_id] logger.info(f"Acknowledged notification {notification_id}") return True return False def check_escalations(self) -> list[tuple[str, str]]: """Check for notifications that need escalation. Returns: List of (notification_id, next_channel) tuples to escalate """ now = _utc_now() to_escalate = [] for notif_id, rule in list(self.pending_escalations.items()): if rule.escalated: continue elapsed = (now - rule.sent_at).total_seconds() / 60 if elapsed >= rule.escalation_delay_minutes: to_escalate.append((notif_id, rule.next_channel)) rule.escalated = True logger.info(f"Escalating notification {notif_id} to {rule.next_channel}") return to_escalate def set_dnd(self, enabled: bool) -> None: """Enable or disable do-not-disturb mode. Args: enabled: True to enable DND, False to disable """ self.dnd_enabled = enabled logger.info(f"Do-not-disturb mode: {'enabled' if enabled else 'disabled'}") # --- Global Service Accessors --- def get_notifier() -> Notifier: """Get the global notifier instance. Returns: The global notifier, creating it if needed """ global _notifier if _notifier is None: _notifier = Notifier(escalation_manager=get_escalation_manager()) return _notifier def set_notifier(notifier: Notifier | None) -> None: """Set the global notifier instance. Args: notifier: The notifier to use """ global _notifier _notifier = notifier def get_escalation_manager() -> EscalationManager: """Get the global escalation manager instance. Returns: The global escalation manager, creating it if needed """ global _escalation_manager if _escalation_manager is None: _escalation_manager = EscalationManager() return _escalation_manager def set_escalation_manager(manager: EscalationManager) -> None: """Set the global escalation manager instance. Args: manager: The escalation manager to use """ global _escalation_manager _escalation_manager = manager