"""Notification management for VAia agent.""" from __future__ import annotations import logging import time from datetime import datetime from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from pydantic import AliasChoices, BaseModel, Field, field_validator if TYPE_CHECKING: from google.cloud.firestore_v1.async_client import AsyncClient logger = logging.getLogger(__name__) class Notification(BaseModel): """A single notification, normalised from either schema. Handles snake_case (``id_notificacion``), camelCase (``idNotificacion``), and English short names (``notificationId``) transparently via ``AliasChoices``. """ id_notificacion: str = Field( validation_alias=AliasChoices( "id_notificacion", "idNotificacion", "notificationId" ), ) texto: str = Field( default="Sin texto", validation_alias=AliasChoices("texto", "text"), ) nombre_evento: str = Field( default="notificacion", validation_alias=AliasChoices( "nombre_evento_dialogflow", "nombreEventoDialogflow", "event" ), ) timestamp_creacion: float = Field( default=0.0, validation_alias=AliasChoices("timestamp_creacion", "timestampCreacion"), ) status: str = "active" parametros: dict[str, Any] = Field( default_factory=dict, validation_alias=AliasChoices("parametros", "parameters"), ) @field_validator("timestamp_creacion", mode="before") @classmethod def _coerce_timestamp(cls, v: Any) -> float: """Normalise Firestore timestamps (float, str, datetime) to float.""" if isinstance(v, (int, float)): return float(v) if isinstance(v, datetime): return v.timestamp() if isinstance(v, str): try: return float(v) except ValueError: return 0.0 return 0.0 class NotificationDocument(BaseModel): """Top-level Firestore / Redis document that wraps a list of notifications. Mirrors the schema used by ``utils/check_notifications.py`` (``NotificationSession``) but keeps only what the agent needs. """ notificaciones: list[Notification] = Field(default_factory=list) @runtime_checkable class NotificationBackend(Protocol): """Backend-agnostic interface for notification storage.""" async def get_recent_notifications(self, phone_number: str) -> list[Notification]: """Return recent notifications for *phone_number*.""" ... async def mark_as_notified( self, phone_number: str, notification_ids: list[str] ) -> bool: """Mark the given notification IDs as notified. Return success.""" ... class FirestoreNotificationBackend: """Firestore-backed notification backend (read-only). Reads notifications from a Firestore document keyed by phone number. Filters by a configurable time window instead of tracking read/unread state — the agent is awareness-only; delivery happens in the app. """ def __init__( self, *, db: AsyncClient, collection_path: str, max_to_notify: int = 5, window_hours: float = 48, ) -> None: """Initialize with Firestore client and collection path.""" self._db = db self._collection_path = collection_path self._max_to_notify = max_to_notify self._window_hours = window_hours async def get_recent_notifications(self, phone_number: str) -> list[Notification]: """Get recent notifications for a user. Retrieves notifications created within the configured time window, ordered by timestamp (most recent first), limited to max_to_notify. Args: phone_number: User's phone number (used as document ID) Returns: List of validated :class:`Notification` instances. """ try: doc_ref = self._db.collection(self._collection_path).document(phone_number) doc = await doc_ref.get() if not doc.exists: logger.info( "No notification document found for phone: %s", phone_number ) return [] data = doc.to_dict() or {} document = NotificationDocument.model_validate(data) if not document.notificaciones: logger.info("No notifications in array for phone: %s", phone_number) return [] cutoff = time.time() - (self._window_hours * 3600) parsed = [ n for n in document.notificaciones if n.timestamp_creacion >= cutoff ] if not parsed: logger.info( "No notifications within the last %.0fh for phone: %s", self._window_hours, phone_number, ) return [] parsed.sort(key=lambda n: n.timestamp_creacion, reverse=True) result = parsed[: self._max_to_notify] logger.info( "Found %d recent notifications for phone: %s (returning top %d)", len(parsed), phone_number, len(result), ) except Exception: logger.exception( "Failed to fetch notifications for phone: %s", phone_number ) return [] else: return result async def mark_as_notified( self, phone_number: str, # noqa: ARG002 notification_ids: list[str], # noqa: ARG002 ) -> bool: """No-op — the agent is not the delivery mechanism.""" return True class RedisNotificationBackend: """Redis-backed notification backend (read-only).""" def __init__( self, *, host: str = "127.0.0.1", port: int = 6379, max_to_notify: int = 5, window_hours: float = 48, ) -> None: """Initialize with Redis connection parameters.""" import redis.asyncio as aioredis # noqa: PLC0415 self._client = aioredis.Redis( host=host, port=port, decode_responses=True, socket_connect_timeout=5, ) self._max_to_notify = max_to_notify self._window_hours = window_hours async def get_recent_notifications(self, phone_number: str) -> list[Notification]: """Get recent notifications for a user from Redis. Reads from the ``notification:{phone}`` key, parses the JSON payload, and returns notifications created within the configured time window, sorted by creation timestamp (most recent first), limited to *max_to_notify*. """ import json # noqa: PLC0415 try: raw = await self._client.get(f"notification:{phone_number}") if not raw: logger.info( "No notification data in Redis for phone: %s", phone_number, ) return [] document = NotificationDocument.model_validate(json.loads(raw)) if not document.notificaciones: logger.info( "No notifications in array for phone: %s", phone_number, ) return [] cutoff = time.time() - (self._window_hours * 3600) parsed = [ n for n in document.notificaciones if n.timestamp_creacion >= cutoff ] if not parsed: logger.info( "No notifications within the last %.0fh for phone: %s", self._window_hours, phone_number, ) return [] parsed.sort(key=lambda n: n.timestamp_creacion, reverse=True) result = parsed[: self._max_to_notify] logger.info( "Found %d recent notifications for phone: %s (returning top %d)", len(parsed), phone_number, len(result), ) except Exception: logger.exception( "Failed to fetch notifications from Redis for phone: %s", phone_number, ) return [] else: return result async def mark_as_notified( self, phone_number: str, # noqa: ARG002 notification_ids: list[str], # noqa: ARG002 ) -> bool: """No-op — the agent is not the delivery mechanism.""" return True