""" Chat Message Service. This service handles all business logic for player-NPC conversation history, including saving messages, retrieving conversations, searching, and managing the recent_messages cache in character documents. """ import json from typing import List, Dict, Any, Optional from datetime import datetime from appwrite.query import Query from appwrite.exception import AppwriteException from appwrite.id import ID from app.models.chat_message import ChatMessage, MessageContext, ConversationSummary from app.services.database_service import get_database_service from app.services.character_service import CharacterService from app.utils.logging import get_logger logger = get_logger(__file__) # Custom Exceptions class ChatMessageNotFound(Exception): """Raised when a chat message is not found.""" pass class ChatMessagePermissionDenied(Exception): """Raised when user doesn't have permission to access/modify a chat message.""" pass class ChatMessageService: """ Service for managing player-NPC chat messages. This service provides: - Saving dialogue exchanges to chat_messages collection - Updating recent_messages cache in character documents - Retrieving conversation history with pagination - Searching messages by text, NPC, context, date range - Getting conversation summaries for UI - Soft deleting messages for privacy/moderation """ def __init__(self): """Initialize the chat message service with database and character service.""" self.db = get_database_service() self.character_service = CharacterService() logger.info("ChatMessageService initialized") def save_dialogue_exchange( self, character_id: str, user_id: str, npc_id: str, player_message: str, npc_response: str, context: MessageContext = MessageContext.DIALOGUE, metadata: Optional[Dict[str, Any]] = None, session_id: Optional[str] = None, location_id: Optional[str] = None ) -> ChatMessage: """ Save a dialogue exchange to chat_messages collection and update character's recent_messages cache. This method: 1. Creates a ChatMessage document in the chat_messages collection 2. Updates character.npc_interactions[npc_id].recent_messages with the last 3 messages 3. Updates character.npc_interactions[npc_id].total_messages counter Args: character_id: Player's character ID user_id: User ID (for ownership validation) npc_id: NPC identifier player_message: What the player said npc_response: NPC's reply context: Type of interaction (default: DIALOGUE) metadata: Optional extensible metadata (quest_id, faction_id, etc.) session_id: Optional game session reference location_id: Optional location where conversation happened Returns: Saved ChatMessage instance Raises: ChatMessagePermissionDenied: If user doesn't own the character AppwriteException: If database operation fails """ try: # Validate ownership self._validate_ownership(character_id, user_id) # Create chat message chat_message = ChatMessage.create( character_id=character_id, npc_id=npc_id, player_message=player_message, npc_response=npc_response, context=context, session_id=session_id, location_id=location_id, metadata=metadata ) # Save to database message_data = chat_message.to_dict() # Convert metadata dict to JSON string for storage if message_data.get('metadata'): message_data['metadata'] = json.dumps(message_data['metadata']) self.db.create_row( table_id='chat_messages', data=message_data, row_id=chat_message.message_id ) logger.info("Chat message saved", message_id=chat_message.message_id, character_id=character_id, npc_id=npc_id, context=context.value) # Update character's recent_messages cache self._update_recent_messages_preview(character_id, user_id, npc_id, chat_message) return chat_message except ChatMessagePermissionDenied: raise except Exception as e: logger.error("Failed to save dialogue exchange", character_id=character_id, npc_id=npc_id, error=str(e)) raise def get_conversation_history( self, character_id: str, user_id: str, npc_id: str, limit: int = 50, offset: int = 0 ) -> List[ChatMessage]: """ Get paginated conversation history between character and specific NPC. Args: character_id: Player's character ID user_id: User ID (for ownership validation) npc_id: NPC identifier limit: Maximum messages to return (default 50, max 100) offset: Number of messages to skip for pagination Returns: List of ChatMessage instances ordered by timestamp DESC (most recent first) Raises: ChatMessagePermissionDenied: If user doesn't own the character AppwriteException: If database query fails """ try: # Validate ownership self._validate_ownership(character_id, user_id) # Clamp limit to max 100 limit = min(limit, 100) # Build query queries = [ Query.equal('character_id', character_id), Query.equal('npc_id', npc_id), Query.equal('is_deleted', False), Query.order_desc('timestamp'), Query.limit(limit), Query.offset(offset) ] # Fetch messages rows = self.db.list_rows( table_id='chat_messages', queries=queries ) # Convert to ChatMessage objects messages = [] for row in rows: message_data = row.data # Parse JSON metadata if present if message_data.get('metadata') and isinstance(message_data['metadata'], str): message_data['metadata'] = json.loads(message_data['metadata']) messages.append(ChatMessage.from_dict(message_data)) logger.info("Retrieved conversation history", character_id=character_id, npc_id=npc_id, count=len(messages), limit=limit, offset=offset) return messages except ChatMessagePermissionDenied: raise except Exception as e: logger.error("Failed to get conversation history", character_id=character_id, npc_id=npc_id, error=str(e)) raise def search_messages( self, character_id: str, user_id: str, search_text: str, npc_id: Optional[str] = None, context: Optional[MessageContext] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, limit: int = 50, offset: int = 0 ) -> List[ChatMessage]: """ Search messages by text with optional filters. Note: Appwrite's full-text search may be limited. This performs basic filtering. For production use, consider implementing a dedicated search service. Args: character_id: Player's character ID user_id: User ID (for ownership validation) search_text: Text to search for in player_message and npc_response npc_id: Optional filter by specific NPC context: Optional filter by message context type date_from: Optional start date (ISO format) date_to: Optional end date (ISO format) limit: Maximum messages to return (default 50) offset: Number of messages to skip for pagination Returns: List of matching ChatMessage instances Raises: ChatMessagePermissionDenied: If user doesn't own the character AppwriteException: If database query fails """ try: # Validate ownership self._validate_ownership(character_id, user_id) # Build base query queries = [ Query.equal('character_id', character_id), Query.equal('is_deleted', False) ] # Add optional filters if npc_id: queries.append(Query.equal('npc_id', npc_id)) if context: queries.append(Query.equal('context', context.value)) if date_from: queries.append(Query.greater_than_equal('timestamp', date_from)) if date_to: queries.append(Query.less_than_equal('timestamp', date_to)) # Add search (Appwrite uses Query.search() if available) if search_text: try: queries.append(Query.search('player_message', search_text)) except: # Fallback: will filter in Python if search not supported pass queries.extend([ Query.order_desc('timestamp'), Query.limit(min(limit, 100)), Query.offset(offset) ]) # Fetch messages rows = self.db.list_rows( table_id='chat_messages', queries=queries ) # Convert to ChatMessage objects and apply text filter if needed messages = [] for row in rows: message_data = row.data # Parse JSON metadata if present if message_data.get('metadata') and isinstance(message_data['metadata'], str): message_data['metadata'] = json.loads(message_data['metadata']) # Filter by search text in Python if not handled by database if search_text: player_msg = message_data.get('player_message', '').lower() npc_msg = message_data.get('npc_response', '').lower() if search_text.lower() not in player_msg and search_text.lower() not in npc_msg: continue messages.append(ChatMessage.from_dict(message_data)) logger.info("Search completed", character_id=character_id, search_text=search_text, npc_id=npc_id, context=context.value if context else None, results=len(messages)) return messages except ChatMessagePermissionDenied: raise except Exception as e: logger.error("Failed to search messages", character_id=character_id, search_text=search_text, error=str(e)) raise def get_all_conversations_summary( self, character_id: str, user_id: str ) -> List[ConversationSummary]: """ Get summary of all NPC conversations for a character. Returns list of NPCs the character has talked to, with message counts, last message timestamp, and preview of most recent exchange. Args: character_id: Player's character ID user_id: User ID (for ownership validation) Returns: List of ConversationSummary instances Raises: ChatMessagePermissionDenied: If user doesn't own the character """ try: # Validate ownership self._validate_ownership(character_id, user_id) # Get character to access npc_interactions character = self.character_service.get_character(character_id, user_id) # Build summaries from character's npc_interactions summaries = [] for npc_id, interaction in character.npc_interactions.items(): # Get NPC name (if available from NPC data, otherwise use npc_id) # TODO: When NPC service is available, fetch NPC name npc_name = npc_id.replace('_', ' ').title() # Get last message timestamp and preview from recent_messages recent_messages = interaction.get('recent_messages', []) last_timestamp = interaction.get('last_interaction', '') recent_preview = '' if recent_messages: last_msg = recent_messages[-1] last_timestamp = last_msg.get('timestamp', last_timestamp) recent_preview = last_msg.get('npc_response', '')[:100] # First 100 chars # Get total message count total_messages = interaction.get('total_messages', interaction.get('interaction_count', 0)) summary = ConversationSummary( npc_id=npc_id, npc_name=npc_name, last_message_timestamp=last_timestamp, message_count=total_messages, recent_preview=recent_preview ) summaries.append(summary) # Sort by most recent first summaries.sort(key=lambda s: s.last_message_timestamp, reverse=True) logger.info("Retrieved conversation summaries", character_id=character_id, conversation_count=len(summaries)) return summaries except ChatMessagePermissionDenied: raise except Exception as e: logger.error("Failed to get conversation summaries", character_id=character_id, error=str(e)) raise def soft_delete_message( self, message_id: str, character_id: str, user_id: str ) -> bool: """ Soft delete a message by setting is_deleted=True. Used for privacy/moderation. Message remains in database but filtered from queries. Args: message_id: Message ID to delete character_id: Character ID (for ownership validation) user_id: User ID (for ownership validation) Returns: True if successful Raises: ChatMessageNotFound: If message not found ChatMessagePermissionDenied: If user doesn't own the character/message """ try: # Validate ownership self._validate_ownership(character_id, user_id) # Fetch message to verify it belongs to this character message_row = self.db.get_row(table_id='chat_messages', row_id=message_id) if not message_row: raise ChatMessageNotFound(f"Message {message_id} not found") if message_row.data.get('character_id') != character_id: raise ChatMessagePermissionDenied("Message does not belong to this character") # Update message to set is_deleted=True self.db.update_row( table_id='chat_messages', row_id=message_id, data={'is_deleted': True} ) logger.info("Message soft deleted", message_id=message_id, character_id=character_id) return True except (ChatMessageNotFound, ChatMessagePermissionDenied): raise except Exception as e: logger.error("Failed to soft delete message", message_id=message_id, error=str(e)) raise # Helper Methods def _update_recent_messages_preview( self, character_id: str, user_id: str, npc_id: str, new_message: ChatMessage ) -> None: """ Update the recent_messages cache in character.npc_interactions[npc_id]. Maintains the last 3 messages for quick AI context retrieval without querying the chat_messages collection. Args: character_id: Character ID user_id: User ID npc_id: NPC ID new_message: New message to add to preview """ try: # Get character character = self.character_service.get_character(character_id, user_id) # Get or create NPC interaction if npc_id not in character.npc_interactions: character.npc_interactions[npc_id] = { 'npc_id': npc_id, 'first_met': new_message.timestamp, 'last_interaction': new_message.timestamp, 'interaction_count': 0, 'revealed_secrets': [], 'relationship_level': 50, 'custom_flags': {}, 'recent_messages': [], 'total_messages': 0 } interaction = character.npc_interactions[npc_id] # Add new message to recent_messages recent_messages = interaction.get('recent_messages', []) recent_messages.append(new_message.to_preview()) # Keep only last 3 messages interaction['recent_messages'] = recent_messages[-3:] # Update total message count interaction['total_messages'] = interaction.get('total_messages', 0) + 1 # Update last_interaction timestamp interaction['last_interaction'] = new_message.timestamp # Save character self.character_service.update_character(character_id, user_id, character) logger.debug("Updated recent_messages preview", character_id=character_id, npc_id=npc_id, recent_count=len(interaction['recent_messages'])) except Exception as e: logger.error("Failed to update recent_messages preview", character_id=character_id, npc_id=npc_id, error=str(e)) # Don't raise - this is a cache update, not critical def _validate_ownership(self, character_id: str, user_id: str) -> None: """ Validate that the user owns the character. Args: character_id: Character ID user_id: User ID Raises: ChatMessagePermissionDenied: If user doesn't own the character """ try: self.character_service.get_character(character_id, user_id) except Exception as e: logger.warning("Ownership validation failed", character_id=character_id, user_id=user_id) raise ChatMessagePermissionDenied(f"User {user_id} does not own character {character_id}") # Global instance for convenience _service_instance: Optional[ChatMessageService] = None def get_chat_message_service() -> ChatMessageService: """ Get the global ChatMessageService instance. Returns: Singleton ChatMessageService instance """ global _service_instance if _service_instance is None: _service_instance = ChatMessageService() return _service_instance