This commit is contained in:
2026-02-19 17:50:14 +00:00
committed by Anibal Angulo
parent b63a1ae4a7
commit 41ba38495b
171 changed files with 7281 additions and 1144 deletions

View File

@@ -0,0 +1,373 @@
"""
Copyright 2025 Google. This software is provided as-is, without warranty or
representation for any use or purpose. Your use of it is subject to your
agreement with Google.
Redis service for caching conversation sessions.
"""
import json
import logging
from datetime import datetime
from redis.asyncio import Redis
from ..config import Settings
from ..models import ConversationSessionDTO
from ..models.notification import NotificationSessionDTO, NotificationDTO
logger = logging.getLogger(__name__)
class RedisService:
"""Service for Redis operations on conversation sessions."""
def __init__(self, settings: Settings):
"""Initialize Redis client."""
self.settings = settings
self.redis: Redis | None = None
self.session_ttl = 2592000 # 30 days in seconds
self.notification_ttl = 2592000 # 30 days in seconds
async def connect(self):
"""Connect to Redis."""
self.redis = Redis(
host=self.settings.redis_host,
port=self.settings.redis_port,
password=self.settings.redis_password,
ssl=self.settings.redis_ssl,
decode_responses=True,
)
logger.info(
f"Connected to Redis at {self.settings.redis_host}:{self.settings.redis_port}"
)
async def close(self):
"""Close Redis connection."""
if self.redis:
await self.redis.close()
logger.info("Redis connection closed")
def _session_key(self, session_id: str) -> str:
"""Generate Redis key for conversation session."""
return f"conversation:session:{session_id}"
def _phone_to_session_key(self, phone: str) -> str:
"""Generate Redis key for phone-to-session mapping."""
return f"conversation:phone:{phone}"
async def get_session(
self, session_id_or_phone: str
) -> ConversationSessionDTO | None:
"""
Retrieve conversation session from Redis by session ID or phone number.
Args:
session_id_or_phone: Either a session ID or phone number
Returns:
Conversation session or None if not found
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
# First try as phone number (lookup session ID)
phone_key = self._phone_to_session_key(session_id_or_phone)
mapped_session_id = await self.redis.get(phone_key)
if mapped_session_id:
# Found phone mapping, get the actual session
session_id = mapped_session_id
else:
# Try as direct session ID
session_id = session_id_or_phone
# Get session by ID
key = self._session_key(session_id)
data = await self.redis.get(key)
if not data:
logger.debug(f"Session not found in Redis: {session_id_or_phone}")
return None
try:
session_dict = json.loads(data)
session = ConversationSessionDTO.model_validate(session_dict)
logger.debug(f"Retrieved session from Redis: {session_id}")
return session
except Exception as e:
logger.error(f"Error deserializing session {session_id}: {str(e)}")
return None
async def save_session(self, session: ConversationSessionDTO) -> bool:
"""
Save conversation session to Redis with TTL.
Also stores phone-to-session mapping for lookup by phone number.
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session.sessionId)
phone_key = self._phone_to_session_key(session.telefono)
try:
# Save session data
data = session.model_dump_json(by_alias=False)
await self.redis.setex(key, self.session_ttl, data)
# Save phone-to-session mapping
await self.redis.setex(phone_key, self.session_ttl, session.sessionId)
logger.debug(
f"Saved session to Redis: {session.sessionId} for phone: {session.telefono}"
)
return True
except Exception as e:
logger.error(f"Error saving session {session.sessionId} to Redis: {str(e)}")
return False
async def delete_session(self, session_id: str) -> bool:
"""Delete conversation session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session_id)
try:
result = await self.redis.delete(key)
logger.debug(f"Deleted session from Redis: {session_id}")
return result > 0
except Exception as e:
logger.error(f"Error deleting session {session_id} from Redis: {str(e)}")
return False
async def exists(self, session_id: str) -> bool:
"""Check if session exists in Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._session_key(session_id)
return await self.redis.exists(key) > 0
# ====== Message Methods ======
def _messages_key(self, session_id: str) -> str:
"""Generate Redis key for conversation messages."""
return f"conversation:messages:{session_id}"
async def save_message(self, session_id: str, message) -> bool:
"""
Save a conversation message to Redis sorted set.
Messages are stored in a sorted set with timestamp as score.
Args:
session_id: The session ID
message: ConversationMessageDTO or ConversationEntryDTO
Returns:
True if successful, False otherwise
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._messages_key(session_id)
try:
# Convert message to JSON
message_data = message.model_dump_json(by_alias=False)
# Use timestamp as score (in milliseconds)
score = message.timestamp.timestamp() * 1000
# Add to sorted set
await self.redis.zadd(key, {message_data: score})
# Set TTL on the messages key to match session TTL
await self.redis.expire(key, self.session_ttl)
logger.debug(f"Saved message to Redis: {session_id}")
return True
except Exception as e:
logger.error(f"Error saving message to Redis for session {session_id}: {str(e)}")
return False
async def get_messages(self, session_id: str) -> list:
"""
Retrieve all conversation messages for a session from Redis.
Returns messages ordered by timestamp (oldest first).
Args:
session_id: The session ID
Returns:
List of message dictionaries (parsed from JSON)
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._messages_key(session_id)
try:
# Get all messages from sorted set (ordered by score/timestamp)
message_strings = await self.redis.zrange(key, 0, -1)
if not message_strings:
logger.debug(f"No messages found in Redis for session: {session_id}")
return []
# Parse JSON strings to dictionaries
messages = []
for msg_str in message_strings:
try:
messages.append(json.loads(msg_str))
except json.JSONDecodeError as e:
logger.error(f"Error parsing message JSON: {str(e)}")
continue
logger.debug(f"Retrieved {len(messages)} messages from Redis for session: {session_id}")
return messages
except Exception as e:
logger.error(f"Error retrieving messages from Redis for session {session_id}: {str(e)}")
return []
# ====== Notification Methods ======
def _notification_key(self, session_id: str) -> str:
"""Generate Redis key for notification session."""
return f"notification:{session_id}"
def _phone_to_notification_key(self, phone: str) -> str:
"""Generate Redis key for phone-to-notification mapping."""
return f"notification:phone_to_notification:{phone}"
async def save_or_append_notification(self, new_entry: NotificationDTO) -> None:
"""
Save or append notification entry to session.
Args:
new_entry: Notification entry to save
Raises:
ValueError: If phone number is missing
"""
if not self.redis:
raise RuntimeError("Redis client not connected")
phone_number = new_entry.telefono
if not phone_number or not phone_number.strip():
raise ValueError("Phone number is required to manage notification entries")
# Use phone number as session ID for notifications
notification_session_id = phone_number
# Get existing session or create new one
existing_session = await self.get_notification_session(notification_session_id)
if existing_session:
# Append to existing session
updated_notifications = existing_session.notificaciones + [new_entry]
updated_session = NotificationSessionDTO(
session_id=notification_session_id,
telefono=phone_number,
fecha_creacion=existing_session.fecha_creacion,
ultima_actualizacion=datetime.now(),
notificaciones=updated_notifications,
)
else:
# Create new session
updated_session = NotificationSessionDTO(
session_id=notification_session_id,
telefono=phone_number,
fecha_creacion=datetime.now(),
ultima_actualizacion=datetime.now(),
notificaciones=[new_entry],
)
# Save to Redis
await self._cache_notification_session(updated_session)
async def _cache_notification_session(
self, session: NotificationSessionDTO
) -> bool:
"""Cache notification session in Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._notification_key(session.sessionId)
phone_key = self._phone_to_notification_key(session.telefono)
try:
# Save notification session
data = session.model_dump_json(by_alias=False)
await self.redis.setex(key, self.notification_ttl, data)
# Save phone-to-session mapping
await self.redis.setex(phone_key, self.notification_ttl, session.sessionId)
logger.debug(f"Cached notification session: {session.sessionId}")
return True
except Exception as e:
logger.error(
f"Error caching notification session {session.sessionId}: {str(e)}"
)
return False
async def get_notification_session(
self, session_id: str
) -> NotificationSessionDTO | None:
"""Retrieve notification session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._notification_key(session_id)
data = await self.redis.get(key)
if not data:
logger.debug(f"Notification session not found in Redis: {session_id}")
return None
try:
session_dict = json.loads(data)
session = NotificationSessionDTO.model_validate(session_dict)
logger.info(f"Notification session {session_id} retrieved from Redis")
return session
except Exception as e:
logger.error(
f"Error deserializing notification session {session_id}: {str(e)}"
)
return None
async def get_notification_id_for_phone(self, phone: str) -> str | None:
"""Get notification session ID for a phone number."""
if not self.redis:
raise RuntimeError("Redis client not connected")
key = self._phone_to_notification_key(phone)
session_id = await self.redis.get(key)
if session_id:
logger.info(f"Session ID {session_id} found for phone")
else:
logger.debug("Session ID not found for phone")
return session_id
async def delete_notification_session(self, phone_number: str) -> bool:
"""Delete notification session from Redis."""
if not self.redis:
raise RuntimeError("Redis client not connected")
notification_key = self._notification_key(phone_number)
phone_key = self._phone_to_notification_key(phone_number)
try:
logger.info(f"Deleting notification session for phone {phone_number}")
await self.redis.delete(notification_key)
await self.redis.delete(phone_key)
return True
except Exception as e:
logger.error(
f"Error deleting notification session for phone {phone_number}: {str(e)}"
)
return False