Fixed two critical bugs in NPC chat functionality:
1. Database Persistence - Metadata serialization bug
- Empty dict {} was falsy, preventing JSON conversion
- Changed to unconditional serialization in ChatMessageService
- Messages now successfully save to chat_messages collection
2. Modal Targeting - HTMX targeting lost during polling
- poll_job() wasn't preserving hx-target/hx-swap parameters
- Pass targeting params through query string in polling cycle
- Responses now correctly load in modal instead of main panel
Files modified:
- api/app/services/chat_message_service.py
- public_web/templates/game/partials/job_polling.html
- public_web/app/views/game_views.py
565 lines
20 KiB
Python
565 lines
20 KiB
Python
"""
|
|
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 (Appwrite requires string type)
|
|
# Always convert, even if empty dict (empty dict {} is falsy in Python!)
|
|
message_data['metadata'] = json.dumps(message_data.get('metadata') or {})
|
|
|
|
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
|