Spaces:
Running
Running
Commit
·
4e15311
1
Parent(s):
fbe31d6
fyp
Browse files- app/database.py +13 -1
- app/models/conversation.py +22 -3
- app/models/message.py +113 -9
- app/routes/conversations.py +177 -0
- app/routes/websocket_chat.py +409 -0
- app/services/conversation_service.py +367 -3
app/database.py
CHANGED
|
@@ -79,7 +79,13 @@ async def ensure_indexes():
|
|
| 79 |
await users_col.create_index("role")
|
| 80 |
await users_col.create_index("isActive")
|
| 81 |
|
| 82 |
-
# OTP indexes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
await otps_col.create_index("identifier")
|
| 84 |
await otps_col.create_index("purpose")
|
| 85 |
await otps_col.create_index([("createdAt", 1)], expireAfterSeconds=900) # 15 min TTL
|
|
@@ -123,6 +129,12 @@ async def ensure_chat_indexes():
|
|
| 123 |
# Conversations indexes
|
| 124 |
# Unique index on participants: ONE conversation per user pair (not per listing)
|
| 125 |
# This enables DM-style conversations where User A & B have a single thread
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
await conversations_col.create_index(
|
| 127 |
"participants",
|
| 128 |
unique=True
|
|
|
|
| 79 |
await users_col.create_index("role")
|
| 80 |
await users_col.create_index("isActive")
|
| 81 |
|
| 82 |
+
# OTP indexes - handle TTL index conflict by dropping if options differ
|
| 83 |
+
try:
|
| 84 |
+
# Try to drop existing TTL index with different options
|
| 85 |
+
await otps_col.drop_index("createdAt_1")
|
| 86 |
+
except Exception:
|
| 87 |
+
pass # Index doesn't exist, that's fine
|
| 88 |
+
|
| 89 |
await otps_col.create_index("identifier")
|
| 90 |
await otps_col.create_index("purpose")
|
| 91 |
await otps_col.create_index([("createdAt", 1)], expireAfterSeconds=900) # 15 min TTL
|
|
|
|
| 129 |
# Conversations indexes
|
| 130 |
# Unique index on participants: ONE conversation per user pair (not per listing)
|
| 131 |
# This enables DM-style conversations where User A & B have a single thread
|
| 132 |
+
# Drop existing non-unique index if it exists to recreate with unique constraint
|
| 133 |
+
try:
|
| 134 |
+
await conversations_col.drop_index("participants_1")
|
| 135 |
+
except Exception:
|
| 136 |
+
pass # Index doesn't exist, that's fine
|
| 137 |
+
|
| 138 |
await conversations_col.create_index(
|
| 139 |
"participants",
|
| 140 |
unique=True
|
app/models/conversation.py
CHANGED
|
@@ -7,6 +7,15 @@ from typing import Optional
|
|
| 7 |
class Conversation:
|
| 8 |
"""Conversation model for MongoDB"""
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
@staticmethod
|
| 11 |
def create_document(
|
| 12 |
listing_id: str,
|
|
@@ -41,15 +50,25 @@ class Conversation:
|
|
| 41 |
if not conversation_doc:
|
| 42 |
return None
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return {
|
| 45 |
"id": str(conversation_doc.get("_id", "")),
|
| 46 |
"listing_id": conversation_doc.get("listing_id"),
|
| 47 |
"participants": conversation_doc.get("participants", []),
|
| 48 |
"listing_title": conversation_doc.get("listing_title"),
|
| 49 |
"listing_image": conversation_doc.get("listing_image"),
|
| 50 |
-
"last_message":
|
| 51 |
"unread_count": conversation_doc.get("unread_count", {}),
|
| 52 |
"status": conversation_doc.get("status", "active"),
|
| 53 |
-
"created_at": conversation_doc.get("created_at"),
|
| 54 |
-
"updated_at": conversation_doc.get("updated_at"),
|
| 55 |
}
|
|
|
|
|
|
| 7 |
class Conversation:
|
| 8 |
"""Conversation model for MongoDB"""
|
| 9 |
|
| 10 |
+
@staticmethod
|
| 11 |
+
def _serialize_datetime(dt) -> Optional[str]:
|
| 12 |
+
"""Convert datetime to ISO string, handling None values"""
|
| 13 |
+
if dt is None:
|
| 14 |
+
return None
|
| 15 |
+
if isinstance(dt, datetime):
|
| 16 |
+
return dt.isoformat()
|
| 17 |
+
return dt # Already a string or other serializable type
|
| 18 |
+
|
| 19 |
@staticmethod
|
| 20 |
def create_document(
|
| 21 |
listing_id: str,
|
|
|
|
| 50 |
if not conversation_doc:
|
| 51 |
return None
|
| 52 |
|
| 53 |
+
# Serialize last_message with proper datetime handling
|
| 54 |
+
last_message = conversation_doc.get("last_message")
|
| 55 |
+
if last_message:
|
| 56 |
+
last_message = {
|
| 57 |
+
"text": last_message.get("text"),
|
| 58 |
+
"sender_id": last_message.get("sender_id"),
|
| 59 |
+
"timestamp": Conversation._serialize_datetime(last_message.get("timestamp")),
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
return {
|
| 63 |
"id": str(conversation_doc.get("_id", "")),
|
| 64 |
"listing_id": conversation_doc.get("listing_id"),
|
| 65 |
"participants": conversation_doc.get("participants", []),
|
| 66 |
"listing_title": conversation_doc.get("listing_title"),
|
| 67 |
"listing_image": conversation_doc.get("listing_image"),
|
| 68 |
+
"last_message": last_message,
|
| 69 |
"unread_count": conversation_doc.get("unread_count", {}),
|
| 70 |
"status": conversation_doc.get("status", "active"),
|
| 71 |
+
"created_at": Conversation._serialize_datetime(conversation_doc.get("created_at")),
|
| 72 |
+
"updated_at": Conversation._serialize_datetime(conversation_doc.get("updated_at")),
|
| 73 |
}
|
| 74 |
+
|
app/models/message.py
CHANGED
|
@@ -2,7 +2,12 @@
|
|
| 2 |
# app/models/message.py - Message Model
|
| 3 |
# ============================================================
|
| 4 |
from datetime import datetime
|
| 5 |
-
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
class Message:
|
| 8 |
"""Message model for MongoDB"""
|
|
@@ -31,26 +36,125 @@ class Message:
|
|
| 31 |
"property_card": property_card, # {listing_id, title, price, currency, bedrooms, bathrooms, location, image_url, listing_type}
|
| 32 |
"is_read": False,
|
| 33 |
"read_at": None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"created_at": now,
|
| 35 |
}
|
| 36 |
|
| 37 |
@staticmethod
|
| 38 |
-
def
|
| 39 |
-
"""
|
| 40 |
-
if
|
| 41 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"id": str(message_doc.get("_id", "")),
|
| 45 |
"conversation_id": message_doc.get("conversation_id"),
|
| 46 |
"sender_id": message_doc.get("sender_id"),
|
| 47 |
"sender_name": message_doc.get("sender_name"),
|
| 48 |
"sender_avatar": message_doc.get("sender_avatar"),
|
| 49 |
"message_type": message_doc.get("message_type"),
|
| 50 |
-
"content": message_doc.get("content"),
|
| 51 |
-
"media": message_doc.get("media"),
|
| 52 |
-
"property_card": message_doc.get("property_card"),
|
| 53 |
"is_read": message_doc.get("is_read", False),
|
| 54 |
-
"read_at": message_doc.get("read_at"),
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
# app/models/message.py - Message Model
|
| 3 |
# ============================================================
|
| 4 |
from datetime import datetime
|
| 5 |
+
from typing import Optional, List, Dict
|
| 6 |
+
|
| 7 |
+
# Time windows for message actions (in minutes)
|
| 8 |
+
EDIT_WINDOW_MINUTES = 15 # Can edit within 15 minutes
|
| 9 |
+
DELETE_FOR_EVERYONE_WINDOW_MINUTES = 60 # Can delete for everyone within 1 hour
|
| 10 |
+
|
| 11 |
|
| 12 |
class Message:
|
| 13 |
"""Message model for MongoDB"""
|
|
|
|
| 36 |
"property_card": property_card, # {listing_id, title, price, currency, bedrooms, bathrooms, location, image_url, listing_type}
|
| 37 |
"is_read": False,
|
| 38 |
"read_at": None,
|
| 39 |
+
# Edit tracking
|
| 40 |
+
"is_edited": False,
|
| 41 |
+
"edited_at": None,
|
| 42 |
+
# Deletion tracking
|
| 43 |
+
"is_deleted": False, # Deleted for everyone
|
| 44 |
+
"deleted_at": None,
|
| 45 |
+
"deleted_for": [], # List of user_ids who deleted this message for themselves
|
| 46 |
+
# Reactions: {emoji: [user_id1, user_id2, ...]}
|
| 47 |
+
"reactions": {},
|
| 48 |
"created_at": now,
|
| 49 |
}
|
| 50 |
|
| 51 |
@staticmethod
|
| 52 |
+
def _serialize_datetime(dt) -> Optional[str]:
|
| 53 |
+
"""Convert datetime to ISO string, handling None values"""
|
| 54 |
+
if dt is None:
|
| 55 |
return None
|
| 56 |
+
if isinstance(dt, datetime):
|
| 57 |
+
return dt.isoformat()
|
| 58 |
+
return dt # Already a string or other serializable type
|
| 59 |
+
|
| 60 |
+
@staticmethod
|
| 61 |
+
def _calculate_action_availability(
|
| 62 |
+
message_doc: dict,
|
| 63 |
+
for_user_id: Optional[str] = None
|
| 64 |
+
) -> dict:
|
| 65 |
+
"""
|
| 66 |
+
Calculate whether edit and delete-for-everyone actions are available.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
dict with can_edit and can_delete_for_everyone flags
|
| 70 |
+
"""
|
| 71 |
+
is_deleted = message_doc.get("is_deleted", False)
|
| 72 |
+
sender_id = message_doc.get("sender_id")
|
| 73 |
+
message_type = message_doc.get("message_type")
|
| 74 |
+
created_at = message_doc.get("created_at")
|
| 75 |
+
|
| 76 |
+
# Default: no actions available
|
| 77 |
+
can_edit = False
|
| 78 |
+
can_delete_for_everyone = False
|
| 79 |
+
|
| 80 |
+
# If message is deleted, no actions available
|
| 81 |
+
if is_deleted:
|
| 82 |
+
return {"can_edit": False, "can_delete_for_everyone": False}
|
| 83 |
+
|
| 84 |
+
# Only sender can edit/delete for everyone
|
| 85 |
+
is_sender = for_user_id and sender_id == for_user_id
|
| 86 |
+
|
| 87 |
+
if is_sender and created_at:
|
| 88 |
+
now = datetime.utcnow()
|
| 89 |
+
minutes_since = (now - created_at).total_seconds() / 60
|
| 90 |
+
|
| 91 |
+
# Edit: only text messages, within 15 minutes
|
| 92 |
+
if message_type == "text" and minutes_since <= EDIT_WINDOW_MINUTES:
|
| 93 |
+
can_edit = True
|
| 94 |
+
|
| 95 |
+
# Delete for everyone: within 1 hour
|
| 96 |
+
if minutes_since <= DELETE_FOR_EVERYONE_WINDOW_MINUTES:
|
| 97 |
+
can_delete_for_everyone = True
|
| 98 |
|
| 99 |
return {
|
| 100 |
+
"can_edit": can_edit,
|
| 101 |
+
"can_delete_for_everyone": can_delete_for_everyone,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
@staticmethod
|
| 105 |
+
def format_response(message_doc: dict, for_user_id: Optional[str] = None) -> Optional[dict]:
|
| 106 |
+
"""
|
| 107 |
+
Format message document for API response.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
message_doc: The message document from MongoDB
|
| 111 |
+
for_user_id: If provided, checks if message is deleted for this user
|
| 112 |
+
and calculates action availability
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Formatted message dict, or None if deleted for the requesting user
|
| 116 |
+
"""
|
| 117 |
+
if not message_doc:
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
# If message is deleted for everyone, show placeholder
|
| 121 |
+
is_deleted = message_doc.get("is_deleted", False)
|
| 122 |
+
|
| 123 |
+
# If deleted specifically for this user, don't return the message
|
| 124 |
+
deleted_for = message_doc.get("deleted_for", [])
|
| 125 |
+
if for_user_id and for_user_id in deleted_for:
|
| 126 |
+
return None # Message was "deleted for me" by this user
|
| 127 |
+
|
| 128 |
+
# Calculate action availability for frontend
|
| 129 |
+
action_availability = Message._calculate_action_availability(message_doc, for_user_id)
|
| 130 |
+
|
| 131 |
+
# Build response
|
| 132 |
+
response = {
|
| 133 |
"id": str(message_doc.get("_id", "")),
|
| 134 |
"conversation_id": message_doc.get("conversation_id"),
|
| 135 |
"sender_id": message_doc.get("sender_id"),
|
| 136 |
"sender_name": message_doc.get("sender_name"),
|
| 137 |
"sender_avatar": message_doc.get("sender_avatar"),
|
| 138 |
"message_type": message_doc.get("message_type"),
|
| 139 |
+
"content": message_doc.get("content") if not is_deleted else None,
|
| 140 |
+
"media": message_doc.get("media") if not is_deleted else None,
|
| 141 |
+
"property_card": message_doc.get("property_card") if not is_deleted else None,
|
| 142 |
"is_read": message_doc.get("is_read", False),
|
| 143 |
+
"read_at": Message._serialize_datetime(message_doc.get("read_at")),
|
| 144 |
+
# Edit fields
|
| 145 |
+
"is_edited": message_doc.get("is_edited", False),
|
| 146 |
+
"edited_at": Message._serialize_datetime(message_doc.get("edited_at")),
|
| 147 |
+
# Deletion status
|
| 148 |
+
"is_deleted": is_deleted,
|
| 149 |
+
"deleted_at": Message._serialize_datetime(message_doc.get("deleted_at")) if is_deleted else None,
|
| 150 |
+
# Reactions
|
| 151 |
+
"reactions": message_doc.get("reactions", {}),
|
| 152 |
+
"created_at": Message._serialize_datetime(message_doc.get("created_at")),
|
| 153 |
+
# Action availability flags for frontend UI
|
| 154 |
+
"can_edit": action_availability["can_edit"],
|
| 155 |
+
"can_delete_for_everyone": action_availability["can_delete_for_everyone"],
|
| 156 |
}
|
| 157 |
+
|
| 158 |
+
return response
|
| 159 |
+
|
| 160 |
+
|
app/routes/conversations.py
CHANGED
|
@@ -275,3 +275,180 @@ async def get_bulk_online_status(
|
|
| 275 |
"success": True,
|
| 276 |
"data": statuses,
|
| 277 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
"success": True,
|
| 276 |
"data": statuses,
|
| 277 |
}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ============================================================
|
| 281 |
+
# MESSAGE ACTIONS: Edit, Delete, Reactions, Clear Chat
|
| 282 |
+
# ============================================================
|
| 283 |
+
|
| 284 |
+
class EditMessageDto(BaseModel):
|
| 285 |
+
content: str
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
class ReactionDto(BaseModel):
|
| 289 |
+
emoji: str
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
@router.put("/{conversation_id}/messages/{message_id}", status_code=status.HTTP_200_OK)
|
| 293 |
+
async def edit_message(
|
| 294 |
+
conversation_id: str,
|
| 295 |
+
message_id: str,
|
| 296 |
+
dto: EditMessageDto,
|
| 297 |
+
current_user: dict = Depends(get_current_user),
|
| 298 |
+
):
|
| 299 |
+
"""
|
| 300 |
+
Edit a message (REST fallback).
|
| 301 |
+
|
| 302 |
+
- Only the sender can edit their own message
|
| 303 |
+
- Edit window: 15 minutes after sending
|
| 304 |
+
- Only text messages can be edited
|
| 305 |
+
|
| 306 |
+
The edit is broadcast to all participants via WebSocket if they're connected.
|
| 307 |
+
"""
|
| 308 |
+
user_id = current_user.get("user_id") or current_user.get("sub")
|
| 309 |
+
|
| 310 |
+
if not user_id:
|
| 311 |
+
raise HTTPException(
|
| 312 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 313 |
+
detail="Could not extract user ID from token"
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
logger.info(f"Edit message: conversation={conversation_id}, message={message_id}, user={user_id}")
|
| 317 |
+
|
| 318 |
+
result = await conversation_service.edit_message(
|
| 319 |
+
conversation_id=conversation_id,
|
| 320 |
+
message_id=message_id,
|
| 321 |
+
user_id=user_id,
|
| 322 |
+
new_content=dto.content,
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
return result
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@router.delete("/{conversation_id}/messages/{message_id}", status_code=status.HTTP_200_OK)
|
| 329 |
+
async def delete_message(
|
| 330 |
+
conversation_id: str,
|
| 331 |
+
message_id: str,
|
| 332 |
+
delete_for: str = Query("me", regex="^(everyone|me)$"),
|
| 333 |
+
current_user: dict = Depends(get_current_user),
|
| 334 |
+
):
|
| 335 |
+
"""
|
| 336 |
+
Delete a message (REST fallback).
|
| 337 |
+
|
| 338 |
+
- **delete_for=everyone**: Delete for all participants (sender only, within 1 hour)
|
| 339 |
+
- **delete_for=me**: Delete only for yourself
|
| 340 |
+
|
| 341 |
+
The deletion is broadcast via WebSocket if deleting for everyone.
|
| 342 |
+
"""
|
| 343 |
+
user_id = current_user.get("user_id") or current_user.get("sub")
|
| 344 |
+
|
| 345 |
+
if not user_id:
|
| 346 |
+
raise HTTPException(
|
| 347 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 348 |
+
detail="Could not extract user ID from token"
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
logger.info(f"Delete message: conversation={conversation_id}, message={message_id}, for={delete_for}, user={user_id}")
|
| 352 |
+
|
| 353 |
+
result = await conversation_service.delete_message(
|
| 354 |
+
conversation_id=conversation_id,
|
| 355 |
+
message_id=message_id,
|
| 356 |
+
user_id=user_id,
|
| 357 |
+
delete_for=delete_for,
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
return result
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
@router.post("/{conversation_id}/messages/{message_id}/reactions", status_code=status.HTTP_200_OK)
|
| 364 |
+
async def add_reaction(
|
| 365 |
+
conversation_id: str,
|
| 366 |
+
message_id: str,
|
| 367 |
+
dto: ReactionDto,
|
| 368 |
+
current_user: dict = Depends(get_current_user),
|
| 369 |
+
):
|
| 370 |
+
"""
|
| 371 |
+
Add an emoji reaction to a message (REST fallback).
|
| 372 |
+
|
| 373 |
+
The reaction is broadcast to all participants via WebSocket.
|
| 374 |
+
"""
|
| 375 |
+
user_id = current_user.get("user_id") or current_user.get("sub")
|
| 376 |
+
|
| 377 |
+
if not user_id:
|
| 378 |
+
raise HTTPException(
|
| 379 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 380 |
+
detail="Could not extract user ID from token"
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
logger.info(f"Add reaction: conversation={conversation_id}, message={message_id}, emoji={dto.emoji}, user={user_id}")
|
| 384 |
+
|
| 385 |
+
result = await conversation_service.add_reaction(
|
| 386 |
+
conversation_id=conversation_id,
|
| 387 |
+
message_id=message_id,
|
| 388 |
+
user_id=user_id,
|
| 389 |
+
emoji=dto.emoji,
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
return result
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
@router.delete("/{conversation_id}/messages/{message_id}/reactions/{emoji}", status_code=status.HTTP_200_OK)
|
| 396 |
+
async def remove_reaction(
|
| 397 |
+
conversation_id: str,
|
| 398 |
+
message_id: str,
|
| 399 |
+
emoji: str,
|
| 400 |
+
current_user: dict = Depends(get_current_user),
|
| 401 |
+
):
|
| 402 |
+
"""
|
| 403 |
+
Remove an emoji reaction from a message (REST fallback).
|
| 404 |
+
|
| 405 |
+
The removal is broadcast to all participants via WebSocket.
|
| 406 |
+
"""
|
| 407 |
+
user_id = current_user.get("user_id") or current_user.get("sub")
|
| 408 |
+
|
| 409 |
+
if not user_id:
|
| 410 |
+
raise HTTPException(
|
| 411 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 412 |
+
detail="Could not extract user ID from token"
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
logger.info(f"Remove reaction: conversation={conversation_id}, message={message_id}, emoji={emoji}, user={user_id}")
|
| 416 |
+
|
| 417 |
+
result = await conversation_service.remove_reaction(
|
| 418 |
+
conversation_id=conversation_id,
|
| 419 |
+
message_id=message_id,
|
| 420 |
+
user_id=user_id,
|
| 421 |
+
emoji=emoji,
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
return result
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
@router.delete("/{conversation_id}/clear", status_code=status.HTTP_200_OK)
|
| 428 |
+
async def clear_chat(
|
| 429 |
+
conversation_id: str,
|
| 430 |
+
current_user: dict = Depends(get_current_user),
|
| 431 |
+
):
|
| 432 |
+
"""
|
| 433 |
+
Clear all messages in a conversation for the current user only.
|
| 434 |
+
|
| 435 |
+
Other participants will still see the messages.
|
| 436 |
+
This operation is NOT broadcast to other users.
|
| 437 |
+
"""
|
| 438 |
+
user_id = current_user.get("user_id") or current_user.get("sub")
|
| 439 |
+
|
| 440 |
+
if not user_id:
|
| 441 |
+
raise HTTPException(
|
| 442 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 443 |
+
detail="Could not extract user ID from token"
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
logger.info(f"Clear chat: conversation={conversation_id}, user={user_id}")
|
| 447 |
+
|
| 448 |
+
result = await conversation_service.clear_chat(
|
| 449 |
+
conversation_id=conversation_id,
|
| 450 |
+
user_id=user_id,
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
return result
|
| 454 |
+
|
app/routes/websocket_chat.py
CHANGED
|
@@ -106,6 +106,11 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
|
|
| 106 |
- typing: User is typing
|
| 107 |
- mark_read: Mark messages as read
|
| 108 |
- heartbeat: Keep connection alive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
Message Types (Server -> Client):
|
| 111 |
- new_message: New message received
|
|
@@ -113,6 +118,11 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
|
|
| 113 |
- message_read: Messages were read
|
| 114 |
- user_status_changed: User came online/offline
|
| 115 |
- heartbeat_ack: Response to heartbeat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
"""
|
| 117 |
|
| 118 |
# Verify JWT token
|
|
@@ -188,6 +198,23 @@ async def websocket_chat_endpoint(websocket: WebSocket, token: str = Query(...))
|
|
| 188 |
"action": "online_status",
|
| 189 |
"statuses": statuses
|
| 190 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
except WebSocketDisconnect:
|
| 193 |
chat_manager.disconnect(websocket)
|
|
@@ -397,6 +424,388 @@ async def broadcast_user_status(user_id: str, is_online: bool):
|
|
| 397 |
await chat_manager.send_to_user(participant_id, status_message)
|
| 398 |
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
# Export manager for use in other modules
|
| 401 |
def get_chat_manager():
|
| 402 |
return chat_manager
|
|
|
|
|
|
| 106 |
- typing: User is typing
|
| 107 |
- mark_read: Mark messages as read
|
| 108 |
- heartbeat: Keep connection alive
|
| 109 |
+
- edit_message: Edit a message (sender only)
|
| 110 |
+
- delete_message: Delete a message (for everyone or for me)
|
| 111 |
+
- add_reaction: Add emoji reaction to a message
|
| 112 |
+
- remove_reaction: Remove emoji reaction from a message
|
| 113 |
+
- clear_chat: Clear all messages for current user
|
| 114 |
|
| 115 |
Message Types (Server -> Client):
|
| 116 |
- new_message: New message received
|
|
|
|
| 118 |
- message_read: Messages were read
|
| 119 |
- user_status_changed: User came online/offline
|
| 120 |
- heartbeat_ack: Response to heartbeat
|
| 121 |
+
- message_edited: A message was edited
|
| 122 |
+
- message_deleted: A message was deleted for everyone
|
| 123 |
+
- reaction_added: Reaction added to a message
|
| 124 |
+
- reaction_removed: Reaction removed from a message
|
| 125 |
+
- chat_cleared: Chat was cleared (confirmation)
|
| 126 |
"""
|
| 127 |
|
| 128 |
# Verify JWT token
|
|
|
|
| 198 |
"action": "online_status",
|
| 199 |
"statuses": statuses
|
| 200 |
})
|
| 201 |
+
|
| 202 |
+
# === NEW ACTIONS ===
|
| 203 |
+
|
| 204 |
+
elif action == "edit_message":
|
| 205 |
+
await handle_edit_message(user_id, message)
|
| 206 |
+
|
| 207 |
+
elif action == "delete_message":
|
| 208 |
+
await handle_delete_message(user_id, message)
|
| 209 |
+
|
| 210 |
+
elif action == "add_reaction":
|
| 211 |
+
await handle_add_reaction(user_id, message)
|
| 212 |
+
|
| 213 |
+
elif action == "remove_reaction":
|
| 214 |
+
await handle_remove_reaction(user_id, message)
|
| 215 |
+
|
| 216 |
+
elif action == "clear_chat":
|
| 217 |
+
await handle_clear_chat(user_id, websocket, message)
|
| 218 |
|
| 219 |
except WebSocketDisconnect:
|
| 220 |
chat_manager.disconnect(websocket)
|
|
|
|
| 424 |
await chat_manager.send_to_user(participant_id, status_message)
|
| 425 |
|
| 426 |
|
| 427 |
+
# ============================================================
|
| 428 |
+
# NEW HANDLERS: Edit, Delete, Reactions, Clear Chat
|
| 429 |
+
# ============================================================
|
| 430 |
+
|
| 431 |
+
async def handle_edit_message(user_id: str, data: dict):
|
| 432 |
+
"""
|
| 433 |
+
Handle editing a message via WebSocket.
|
| 434 |
+
Only the sender can edit, within 24 hours, text messages only.
|
| 435 |
+
Broadcasts the edit to all participants.
|
| 436 |
+
"""
|
| 437 |
+
conversation_id = data.get("conversation_id")
|
| 438 |
+
message_id = data.get("message_id")
|
| 439 |
+
new_content = data.get("new_content", "").strip()
|
| 440 |
+
|
| 441 |
+
if not conversation_id or not message_id or not new_content:
|
| 442 |
+
return
|
| 443 |
+
|
| 444 |
+
db = await get_db()
|
| 445 |
+
|
| 446 |
+
# Validate IDs
|
| 447 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 448 |
+
return
|
| 449 |
+
|
| 450 |
+
# Get the message
|
| 451 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 452 |
+
if not message_doc:
|
| 453 |
+
return
|
| 454 |
+
|
| 455 |
+
# Verify sender is the one editing
|
| 456 |
+
if message_doc.get("sender_id") != user_id:
|
| 457 |
+
logger.warning(f"[Chat WS] User {user_id} tried to edit message they didn't send")
|
| 458 |
+
return
|
| 459 |
+
|
| 460 |
+
# Verify message is in the correct conversation
|
| 461 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 462 |
+
return
|
| 463 |
+
|
| 464 |
+
# Only allow editing text messages
|
| 465 |
+
if message_doc.get("message_type") != "text":
|
| 466 |
+
logger.warning(f"[Chat WS] Cannot edit non-text message")
|
| 467 |
+
return
|
| 468 |
+
|
| 469 |
+
# Check 15-minute edit window
|
| 470 |
+
created_at = message_doc.get("created_at")
|
| 471 |
+
if created_at:
|
| 472 |
+
minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
|
| 473 |
+
if minutes_since > 15:
|
| 474 |
+
logger.warning(f"[Chat WS] Edit window expired for message {message_id}")
|
| 475 |
+
return
|
| 476 |
+
|
| 477 |
+
# Cannot edit deleted messages
|
| 478 |
+
if message_doc.get("is_deleted"):
|
| 479 |
+
return
|
| 480 |
+
|
| 481 |
+
# Update the message
|
| 482 |
+
now = datetime.utcnow()
|
| 483 |
+
await db.messages.update_one(
|
| 484 |
+
{"_id": ObjectId(message_id)},
|
| 485 |
+
{
|
| 486 |
+
"$set": {
|
| 487 |
+
"content": new_content,
|
| 488 |
+
"is_edited": True,
|
| 489 |
+
"edited_at": now,
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
# Get conversation for participants
|
| 495 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 496 |
+
if not conversation:
|
| 497 |
+
return
|
| 498 |
+
|
| 499 |
+
# Broadcast to all participants
|
| 500 |
+
edit_broadcast = {
|
| 501 |
+
"action": "message_edited",
|
| 502 |
+
"conversation_id": conversation_id,
|
| 503 |
+
"message_id": message_id,
|
| 504 |
+
"new_content": new_content,
|
| 505 |
+
"edited_at": now.isoformat(),
|
| 506 |
+
"edited_by": user_id,
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
await chat_manager.broadcast_to_conversation(
|
| 510 |
+
conversation_id,
|
| 511 |
+
conversation["participants"],
|
| 512 |
+
edit_broadcast
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
logger.info(f"[Chat WS] Message {message_id} edited by {user_id}")
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
async def handle_delete_message(user_id: str, data: dict):
|
| 519 |
+
"""
|
| 520 |
+
Handle deleting a message via WebSocket.
|
| 521 |
+
|
| 522 |
+
delete_for options:
|
| 523 |
+
- "everyone": Marks message as deleted for all (sender only, within 1 hour)
|
| 524 |
+
- "me": Hides message only for the requesting user
|
| 525 |
+
"""
|
| 526 |
+
conversation_id = data.get("conversation_id")
|
| 527 |
+
message_id = data.get("message_id")
|
| 528 |
+
delete_for = data.get("delete_for", "me") # "everyone" or "me"
|
| 529 |
+
|
| 530 |
+
if not conversation_id or not message_id:
|
| 531 |
+
return
|
| 532 |
+
|
| 533 |
+
db = await get_db()
|
| 534 |
+
|
| 535 |
+
# Validate IDs
|
| 536 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 537 |
+
return
|
| 538 |
+
|
| 539 |
+
# Get the message
|
| 540 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 541 |
+
if not message_doc:
|
| 542 |
+
return
|
| 543 |
+
|
| 544 |
+
# Verify message is in the correct conversation
|
| 545 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 546 |
+
return
|
| 547 |
+
|
| 548 |
+
# Get conversation for validation and broadcast
|
| 549 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 550 |
+
if not conversation:
|
| 551 |
+
return
|
| 552 |
+
|
| 553 |
+
# Verify user is a participant
|
| 554 |
+
if user_id not in conversation.get("participants", []):
|
| 555 |
+
return
|
| 556 |
+
|
| 557 |
+
now = datetime.utcnow()
|
| 558 |
+
|
| 559 |
+
if delete_for == "everyone":
|
| 560 |
+
# Only sender can delete for everyone
|
| 561 |
+
if message_doc.get("sender_id") != user_id:
|
| 562 |
+
logger.warning(f"[Chat WS] User {user_id} tried to delete message for everyone but isn't sender")
|
| 563 |
+
return
|
| 564 |
+
|
| 565 |
+
# Check 1-hour delete window
|
| 566 |
+
created_at = message_doc.get("created_at")
|
| 567 |
+
if created_at:
|
| 568 |
+
hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
|
| 569 |
+
if hours_since > 1:
|
| 570 |
+
logger.warning(f"[Chat WS] Delete-for-everyone window expired for message {message_id}")
|
| 571 |
+
return
|
| 572 |
+
|
| 573 |
+
# Mark as deleted for everyone
|
| 574 |
+
await db.messages.update_one(
|
| 575 |
+
{"_id": ObjectId(message_id)},
|
| 576 |
+
{
|
| 577 |
+
"$set": {
|
| 578 |
+
"is_deleted": True,
|
| 579 |
+
"deleted_at": now,
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
# Broadcast to all participants
|
| 585 |
+
delete_broadcast = {
|
| 586 |
+
"action": "message_deleted",
|
| 587 |
+
"conversation_id": conversation_id,
|
| 588 |
+
"message_id": message_id,
|
| 589 |
+
"deleted_for": "everyone",
|
| 590 |
+
"deleted_at": now.isoformat(),
|
| 591 |
+
"deleted_by": user_id,
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
await chat_manager.broadcast_to_conversation(
|
| 595 |
+
conversation_id,
|
| 596 |
+
conversation["participants"],
|
| 597 |
+
delete_broadcast
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
logger.info(f"[Chat WS] Message {message_id} deleted for everyone by {user_id}")
|
| 601 |
+
|
| 602 |
+
else: # delete_for == "me"
|
| 603 |
+
# Add user to deleted_for list
|
| 604 |
+
await db.messages.update_one(
|
| 605 |
+
{"_id": ObjectId(message_id)},
|
| 606 |
+
{
|
| 607 |
+
"$addToSet": {"deleted_for": user_id}
|
| 608 |
+
}
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
# Only send confirmation to the requesting user (not broadcast)
|
| 612 |
+
delete_confirm = {
|
| 613 |
+
"action": "message_deleted",
|
| 614 |
+
"conversation_id": conversation_id,
|
| 615 |
+
"message_id": message_id,
|
| 616 |
+
"deleted_for": "me",
|
| 617 |
+
"deleted_at": now.isoformat(),
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
await chat_manager.send_to_user(user_id, delete_confirm)
|
| 621 |
+
|
| 622 |
+
logger.info(f"[Chat WS] Message {message_id} deleted for {user_id} only")
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
async def handle_add_reaction(user_id: str, data: dict):
|
| 626 |
+
"""
|
| 627 |
+
Handle adding an emoji reaction to a message.
|
| 628 |
+
Broadcasts the reaction to all participants.
|
| 629 |
+
"""
|
| 630 |
+
conversation_id = data.get("conversation_id")
|
| 631 |
+
message_id = data.get("message_id")
|
| 632 |
+
emoji = data.get("emoji", "").strip()
|
| 633 |
+
|
| 634 |
+
if not conversation_id or not message_id or not emoji:
|
| 635 |
+
return
|
| 636 |
+
|
| 637 |
+
db = await get_db()
|
| 638 |
+
|
| 639 |
+
# Validate IDs
|
| 640 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 641 |
+
return
|
| 642 |
+
|
| 643 |
+
# Get the message
|
| 644 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 645 |
+
if not message_doc:
|
| 646 |
+
return
|
| 647 |
+
|
| 648 |
+
# Verify message is in the correct conversation
|
| 649 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 650 |
+
return
|
| 651 |
+
|
| 652 |
+
# Get conversation for validation and broadcast
|
| 653 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 654 |
+
if not conversation:
|
| 655 |
+
return
|
| 656 |
+
|
| 657 |
+
# Verify user is a participant
|
| 658 |
+
if user_id not in conversation.get("participants", []):
|
| 659 |
+
return
|
| 660 |
+
|
| 661 |
+
# Cannot react to deleted messages
|
| 662 |
+
if message_doc.get("is_deleted"):
|
| 663 |
+
return
|
| 664 |
+
|
| 665 |
+
# Add reaction: reactions is a dict like {"👍": ["user1", "user2"]}
|
| 666 |
+
await db.messages.update_one(
|
| 667 |
+
{"_id": ObjectId(message_id)},
|
| 668 |
+
{
|
| 669 |
+
"$addToSet": {f"reactions.{emoji}": user_id}
|
| 670 |
+
}
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
# Broadcast to all participants
|
| 674 |
+
reaction_broadcast = {
|
| 675 |
+
"action": "reaction_added",
|
| 676 |
+
"conversation_id": conversation_id,
|
| 677 |
+
"message_id": message_id,
|
| 678 |
+
"emoji": emoji,
|
| 679 |
+
"user_id": user_id,
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
await chat_manager.broadcast_to_conversation(
|
| 683 |
+
conversation_id,
|
| 684 |
+
conversation["participants"],
|
| 685 |
+
reaction_broadcast
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
logger.info(f"[Chat WS] Reaction {emoji} added to message {message_id} by {user_id}")
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
async def handle_remove_reaction(user_id: str, data: dict):
|
| 692 |
+
"""
|
| 693 |
+
Handle removing an emoji reaction from a message.
|
| 694 |
+
Broadcasts the removal to all participants.
|
| 695 |
+
"""
|
| 696 |
+
conversation_id = data.get("conversation_id")
|
| 697 |
+
message_id = data.get("message_id")
|
| 698 |
+
emoji = data.get("emoji", "").strip()
|
| 699 |
+
|
| 700 |
+
if not conversation_id or not message_id or not emoji:
|
| 701 |
+
return
|
| 702 |
+
|
| 703 |
+
db = await get_db()
|
| 704 |
+
|
| 705 |
+
# Validate IDs
|
| 706 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 707 |
+
return
|
| 708 |
+
|
| 709 |
+
# Get the message
|
| 710 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 711 |
+
if not message_doc:
|
| 712 |
+
return
|
| 713 |
+
|
| 714 |
+
# Verify message is in the correct conversation
|
| 715 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 716 |
+
return
|
| 717 |
+
|
| 718 |
+
# Get conversation for validation and broadcast
|
| 719 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 720 |
+
if not conversation:
|
| 721 |
+
return
|
| 722 |
+
|
| 723 |
+
# Verify user is a participant
|
| 724 |
+
if user_id not in conversation.get("participants", []):
|
| 725 |
+
return
|
| 726 |
+
|
| 727 |
+
# Remove user from the emoji's reactions list
|
| 728 |
+
await db.messages.update_one(
|
| 729 |
+
{"_id": ObjectId(message_id)},
|
| 730 |
+
{
|
| 731 |
+
"$pull": {f"reactions.{emoji}": user_id}
|
| 732 |
+
}
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
# Clean up empty reaction arrays (optional optimization)
|
| 736 |
+
# If an emoji has no more users, remove the key
|
| 737 |
+
updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 738 |
+
if updated_msg:
|
| 739 |
+
reactions = updated_msg.get("reactions", {})
|
| 740 |
+
if emoji in reactions and len(reactions[emoji]) == 0:
|
| 741 |
+
await db.messages.update_one(
|
| 742 |
+
{"_id": ObjectId(message_id)},
|
| 743 |
+
{"$unset": {f"reactions.{emoji}": ""}}
|
| 744 |
+
)
|
| 745 |
+
|
| 746 |
+
# Broadcast to all participants
|
| 747 |
+
reaction_broadcast = {
|
| 748 |
+
"action": "reaction_removed",
|
| 749 |
+
"conversation_id": conversation_id,
|
| 750 |
+
"message_id": message_id,
|
| 751 |
+
"emoji": emoji,
|
| 752 |
+
"user_id": user_id,
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
await chat_manager.broadcast_to_conversation(
|
| 756 |
+
conversation_id,
|
| 757 |
+
conversation["participants"],
|
| 758 |
+
reaction_broadcast
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
logger.info(f"[Chat WS] Reaction {emoji} removed from message {message_id} by {user_id}")
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
async def handle_clear_chat(user_id: str, websocket: WebSocket, data: dict):
|
| 765 |
+
"""
|
| 766 |
+
Handle clearing all messages in a conversation for the current user only.
|
| 767 |
+
Other participants still see the messages.
|
| 768 |
+
"""
|
| 769 |
+
conversation_id = data.get("conversation_id")
|
| 770 |
+
|
| 771 |
+
if not conversation_id:
|
| 772 |
+
return
|
| 773 |
+
|
| 774 |
+
db = await get_db()
|
| 775 |
+
|
| 776 |
+
# Validate ID
|
| 777 |
+
if not ObjectId.is_valid(conversation_id):
|
| 778 |
+
return
|
| 779 |
+
|
| 780 |
+
# Get conversation for validation
|
| 781 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 782 |
+
if not conversation:
|
| 783 |
+
return
|
| 784 |
+
|
| 785 |
+
# Verify user is a participant
|
| 786 |
+
if user_id not in conversation.get("participants", []):
|
| 787 |
+
return
|
| 788 |
+
|
| 789 |
+
# Add user to deleted_for for ALL messages in this conversation
|
| 790 |
+
result = await db.messages.update_many(
|
| 791 |
+
{"conversation_id": conversation_id},
|
| 792 |
+
{"$addToSet": {"deleted_for": user_id}}
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
# Send confirmation to the requesting user only
|
| 796 |
+
clear_confirm = {
|
| 797 |
+
"action": "chat_cleared",
|
| 798 |
+
"conversation_id": conversation_id,
|
| 799 |
+
"cleared_count": result.modified_count,
|
| 800 |
+
"cleared_at": datetime.utcnow().isoformat(),
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
await websocket.send_json(clear_confirm)
|
| 804 |
+
|
| 805 |
+
logger.info(f"[Chat WS] Chat {conversation_id} cleared for {user_id} ({result.modified_count} messages)")
|
| 806 |
+
|
| 807 |
+
|
| 808 |
# Export manager for use in other modules
|
| 809 |
def get_chat_manager():
|
| 810 |
return chat_manager
|
| 811 |
+
|
app/services/conversation_service.py
CHANGED
|
@@ -171,8 +171,11 @@ class ConversationService:
|
|
| 171 |
detail="You are not a participant in this conversation"
|
| 172 |
)
|
| 173 |
|
| 174 |
-
# Build query
|
| 175 |
-
query = {
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
if before_id and ObjectId.is_valid(before_id):
|
| 178 |
query["_id"] = {"$lt": ObjectId(before_id)}
|
|
@@ -183,7 +186,10 @@ class ConversationService:
|
|
| 183 |
|
| 184 |
messages = []
|
| 185 |
async for doc in cursor:
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
|
| 189 |
|
|
@@ -331,7 +337,365 @@ class ConversationService:
|
|
| 331 |
"success": True,
|
| 332 |
"message": "Messages marked as read",
|
| 333 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
|
| 336 |
# Singleton instance
|
| 337 |
conversation_service = ConversationService()
|
|
|
|
|
|
| 171 |
detail="You are not a participant in this conversation"
|
| 172 |
)
|
| 173 |
|
| 174 |
+
# Build query - exclude messages deleted specifically for this user
|
| 175 |
+
query = {
|
| 176 |
+
"conversation_id": conversation_id,
|
| 177 |
+
"deleted_for": {"$ne": current_user_id}, # Exclude messages deleted for this user
|
| 178 |
+
}
|
| 179 |
|
| 180 |
if before_id and ObjectId.is_valid(before_id):
|
| 181 |
query["_id"] = {"$lt": ObjectId(before_id)}
|
|
|
|
| 186 |
|
| 187 |
messages = []
|
| 188 |
async for doc in cursor:
|
| 189 |
+
# Pass user_id for proper filtering in format_response
|
| 190 |
+
formatted = Message.format_response(doc, for_user_id=current_user_id)
|
| 191 |
+
if formatted: # Only add if not None (handles edge cases)
|
| 192 |
+
messages.append(formatted)
|
| 193 |
|
| 194 |
logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
|
| 195 |
|
|
|
|
| 337 |
"success": True,
|
| 338 |
"message": "Messages marked as read",
|
| 339 |
}
|
| 340 |
+
|
| 341 |
+
# ============================================================
|
| 342 |
+
# NEW METHODS: Edit, Delete, Reactions, Clear Chat
|
| 343 |
+
# ============================================================
|
| 344 |
+
|
| 345 |
+
async def edit_message(
|
| 346 |
+
self,
|
| 347 |
+
conversation_id: str,
|
| 348 |
+
message_id: str,
|
| 349 |
+
user_id: str,
|
| 350 |
+
new_content: str,
|
| 351 |
+
) -> dict:
|
| 352 |
+
"""
|
| 353 |
+
Edit a message content.
|
| 354 |
+
Only the sender can edit, within 24 hours, text messages only.
|
| 355 |
+
"""
|
| 356 |
+
db = await get_db()
|
| 357 |
+
|
| 358 |
+
# Validate IDs
|
| 359 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 360 |
+
raise HTTPException(
|
| 361 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 362 |
+
detail="Invalid ID format"
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# Get the message
|
| 366 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 367 |
+
if not message_doc:
|
| 368 |
+
raise HTTPException(
|
| 369 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 370 |
+
detail="Message not found"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
# Verify sender
|
| 374 |
+
if message_doc.get("sender_id") != user_id:
|
| 375 |
+
raise HTTPException(
|
| 376 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 377 |
+
detail="You can only edit your own messages"
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# Verify message is in the correct conversation
|
| 381 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 382 |
+
raise HTTPException(
|
| 383 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 384 |
+
detail="Message does not belong to this conversation"
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
# Only allow editing text messages
|
| 388 |
+
if message_doc.get("message_type") != "text":
|
| 389 |
+
raise HTTPException(
|
| 390 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 391 |
+
detail="Only text messages can be edited"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# Check 15-minute edit window
|
| 395 |
+
created_at = message_doc.get("created_at")
|
| 396 |
+
if created_at:
|
| 397 |
+
minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
|
| 398 |
+
if minutes_since > 15:
|
| 399 |
+
raise HTTPException(
|
| 400 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 401 |
+
detail="Edit window expired (15 minutes)"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
# Cannot edit deleted messages
|
| 405 |
+
if message_doc.get("is_deleted"):
|
| 406 |
+
raise HTTPException(
|
| 407 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 408 |
+
detail="Cannot edit a deleted message"
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
# Update the message
|
| 412 |
+
now = datetime.utcnow()
|
| 413 |
+
await db.messages.update_one(
|
| 414 |
+
{"_id": ObjectId(message_id)},
|
| 415 |
+
{
|
| 416 |
+
"$set": {
|
| 417 |
+
"content": new_content.strip(),
|
| 418 |
+
"is_edited": True,
|
| 419 |
+
"edited_at": now,
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
logger.info(f"Message {message_id} edited by {user_id}")
|
| 425 |
+
|
| 426 |
+
return {
|
| 427 |
+
"success": True,
|
| 428 |
+
"message_id": message_id,
|
| 429 |
+
"new_content": new_content.strip(),
|
| 430 |
+
"edited_at": now.isoformat(),
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
async def delete_message(
|
| 434 |
+
self,
|
| 435 |
+
conversation_id: str,
|
| 436 |
+
message_id: str,
|
| 437 |
+
user_id: str,
|
| 438 |
+
delete_for: str = "me", # "everyone" or "me"
|
| 439 |
+
) -> dict:
|
| 440 |
+
"""
|
| 441 |
+
Delete a message.
|
| 442 |
+
- "everyone": Only sender, within 1 hour
|
| 443 |
+
- "me": Any participant
|
| 444 |
+
"""
|
| 445 |
+
db = await get_db()
|
| 446 |
+
|
| 447 |
+
# Validate IDs
|
| 448 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 449 |
+
raise HTTPException(
|
| 450 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 451 |
+
detail="Invalid ID format"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
# Get the message
|
| 455 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 456 |
+
if not message_doc:
|
| 457 |
+
raise HTTPException(
|
| 458 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 459 |
+
detail="Message not found"
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
# Verify message is in the correct conversation
|
| 463 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 464 |
+
raise HTTPException(
|
| 465 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 466 |
+
detail="Message does not belong to this conversation"
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Verify user is a participant
|
| 470 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 471 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 472 |
+
raise HTTPException(
|
| 473 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 474 |
+
detail="You are not a participant in this conversation"
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
now = datetime.utcnow()
|
| 478 |
+
|
| 479 |
+
if delete_for == "everyone":
|
| 480 |
+
# Only sender can delete for everyone
|
| 481 |
+
if message_doc.get("sender_id") != user_id:
|
| 482 |
+
raise HTTPException(
|
| 483 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 484 |
+
detail="Only the sender can delete for everyone"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
# Check 1-hour delete window
|
| 488 |
+
created_at = message_doc.get("created_at")
|
| 489 |
+
if created_at:
|
| 490 |
+
hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
|
| 491 |
+
if hours_since > 1:
|
| 492 |
+
raise HTTPException(
|
| 493 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 494 |
+
detail="Delete-for-everyone window expired (1 hour)"
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
# Mark as deleted for everyone
|
| 498 |
+
await db.messages.update_one(
|
| 499 |
+
{"_id": ObjectId(message_id)},
|
| 500 |
+
{
|
| 501 |
+
"$set": {
|
| 502 |
+
"is_deleted": True,
|
| 503 |
+
"deleted_at": now,
|
| 504 |
+
}
|
| 505 |
+
}
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
logger.info(f"Message {message_id} deleted for everyone by {user_id}")
|
| 509 |
+
|
| 510 |
+
return {
|
| 511 |
+
"success": True,
|
| 512 |
+
"message_id": message_id,
|
| 513 |
+
"deleted_for": "everyone",
|
| 514 |
+
"deleted_at": now.isoformat(),
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
else: # delete_for == "me"
|
| 518 |
+
# Add user to deleted_for list
|
| 519 |
+
await db.messages.update_one(
|
| 520 |
+
{"_id": ObjectId(message_id)},
|
| 521 |
+
{"$addToSet": {"deleted_for": user_id}}
|
| 522 |
+
)
|
| 523 |
+
|
| 524 |
+
logger.info(f"Message {message_id} deleted for {user_id} only")
|
| 525 |
+
|
| 526 |
+
return {
|
| 527 |
+
"success": True,
|
| 528 |
+
"message_id": message_id,
|
| 529 |
+
"deleted_for": "me",
|
| 530 |
+
"deleted_at": now.isoformat(),
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
async def add_reaction(
|
| 534 |
+
self,
|
| 535 |
+
conversation_id: str,
|
| 536 |
+
message_id: str,
|
| 537 |
+
user_id: str,
|
| 538 |
+
emoji: str,
|
| 539 |
+
) -> dict:
|
| 540 |
+
"""Add an emoji reaction to a message."""
|
| 541 |
+
db = await get_db()
|
| 542 |
+
|
| 543 |
+
# Validate IDs
|
| 544 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 545 |
+
raise HTTPException(
|
| 546 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 547 |
+
detail="Invalid ID format"
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
# Get the message
|
| 551 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 552 |
+
if not message_doc:
|
| 553 |
+
raise HTTPException(
|
| 554 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 555 |
+
detail="Message not found"
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Verify message is in the correct conversation
|
| 559 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 560 |
+
raise HTTPException(
|
| 561 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 562 |
+
detail="Message does not belong to this conversation"
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
# Verify user is a participant
|
| 566 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 567 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 568 |
+
raise HTTPException(
|
| 569 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 570 |
+
detail="You are not a participant in this conversation"
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
# Cannot react to deleted messages
|
| 574 |
+
if message_doc.get("is_deleted"):
|
| 575 |
+
raise HTTPException(
|
| 576 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 577 |
+
detail="Cannot react to a deleted message"
|
| 578 |
+
)
|
| 579 |
+
|
| 580 |
+
# Add reaction
|
| 581 |
+
await db.messages.update_one(
|
| 582 |
+
{"_id": ObjectId(message_id)},
|
| 583 |
+
{"$addToSet": {f"reactions.{emoji}": user_id}}
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
logger.info(f"Reaction {emoji} added to message {message_id} by {user_id}")
|
| 587 |
+
|
| 588 |
+
return {
|
| 589 |
+
"success": True,
|
| 590 |
+
"message_id": message_id,
|
| 591 |
+
"emoji": emoji,
|
| 592 |
+
"user_id": user_id,
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
async def remove_reaction(
|
| 596 |
+
self,
|
| 597 |
+
conversation_id: str,
|
| 598 |
+
message_id: str,
|
| 599 |
+
user_id: str,
|
| 600 |
+
emoji: str,
|
| 601 |
+
) -> dict:
|
| 602 |
+
"""Remove an emoji reaction from a message."""
|
| 603 |
+
db = await get_db()
|
| 604 |
+
|
| 605 |
+
# Validate IDs
|
| 606 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 607 |
+
raise HTTPException(
|
| 608 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 609 |
+
detail="Invalid ID format"
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
# Get the message
|
| 613 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 614 |
+
if not message_doc:
|
| 615 |
+
raise HTTPException(
|
| 616 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 617 |
+
detail="Message not found"
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
# Verify message is in the correct conversation
|
| 621 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 622 |
+
raise HTTPException(
|
| 623 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 624 |
+
detail="Message does not belong to this conversation"
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Verify user is a participant
|
| 628 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 629 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 630 |
+
raise HTTPException(
|
| 631 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 632 |
+
detail="You are not a participant in this conversation"
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
# Remove reaction
|
| 636 |
+
await db.messages.update_one(
|
| 637 |
+
{"_id": ObjectId(message_id)},
|
| 638 |
+
{"$pull": {f"reactions.{emoji}": user_id}}
|
| 639 |
+
)
|
| 640 |
+
|
| 641 |
+
# Clean up empty reaction arrays
|
| 642 |
+
updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 643 |
+
if updated_msg:
|
| 644 |
+
reactions = updated_msg.get("reactions", {})
|
| 645 |
+
if emoji in reactions and len(reactions[emoji]) == 0:
|
| 646 |
+
await db.messages.update_one(
|
| 647 |
+
{"_id": ObjectId(message_id)},
|
| 648 |
+
{"$unset": {f"reactions.{emoji}": ""}}
|
| 649 |
+
)
|
| 650 |
+
|
| 651 |
+
logger.info(f"Reaction {emoji} removed from message {message_id} by {user_id}")
|
| 652 |
+
|
| 653 |
+
return {
|
| 654 |
+
"success": True,
|
| 655 |
+
"message_id": message_id,
|
| 656 |
+
"emoji": emoji,
|
| 657 |
+
"user_id": user_id,
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
async def clear_chat(
|
| 661 |
+
self,
|
| 662 |
+
conversation_id: str,
|
| 663 |
+
user_id: str,
|
| 664 |
+
) -> dict:
|
| 665 |
+
"""Clear all messages in a conversation for the current user only."""
|
| 666 |
+
db = await get_db()
|
| 667 |
+
|
| 668 |
+
# Validate ID
|
| 669 |
+
if not ObjectId.is_valid(conversation_id):
|
| 670 |
+
raise HTTPException(
|
| 671 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 672 |
+
detail="Invalid conversation ID format"
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
# Verify user is a participant
|
| 676 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 677 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 678 |
+
raise HTTPException(
|
| 679 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 680 |
+
detail="You are not a participant in this conversation"
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
# Add user to deleted_for for ALL messages in this conversation
|
| 684 |
+
result = await db.messages.update_many(
|
| 685 |
+
{"conversation_id": conversation_id},
|
| 686 |
+
{"$addToSet": {"deleted_for": user_id}}
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
logger.info(f"Chat {conversation_id} cleared for {user_id} ({result.modified_count} messages)")
|
| 690 |
+
|
| 691 |
+
return {
|
| 692 |
+
"success": True,
|
| 693 |
+
"conversation_id": conversation_id,
|
| 694 |
+
"cleared_count": result.modified_count,
|
| 695 |
+
"cleared_at": datetime.utcnow().isoformat(),
|
| 696 |
+
}
|
| 697 |
|
| 698 |
|
| 699 |
# Singleton instance
|
| 700 |
conversation_service = ConversationService()
|
| 701 |
+
|