Spaces:
Running
Running
Commit
·
ee7602c
1
Parent(s):
310bac1
mvp fix
Browse files- app/ai/routes/chat.py +113 -70
- app/database.py +8 -1
- app/middleware/__init__.py +29 -0
- app/middleware/rate_limiter.py +156 -0
- app/routes/auth.py +4 -2
- app/routes/websocket_chat.py +73 -11
- app/services/conversation_parts/__init__.py +4 -0
- app/services/conversation_parts/action_mixin.py +475 -0
- app/services/conversation_parts/ai_mixin.py +223 -0
- app/services/conversation_parts/crud_mixin.py +485 -0
- app/services/conversation_parts/message_mixin.py +334 -0
- app/services/conversation_service.py +22 -1516
- app/services/redis_pubsub.py +102 -0
- main.py +17 -0
- requirements.txt +1 -0
app/ai/routes/chat.py
CHANGED
|
@@ -3,16 +3,21 @@
|
|
| 3 |
AIDA Chat Endpoint - Brain-Based AI Agent
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from typing import Optional, Dict, Any
|
| 9 |
from structlog import get_logger
|
| 10 |
from uuid import uuid4
|
| 11 |
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
| 12 |
from langgraph.types import Command
|
| 13 |
|
| 14 |
from app.ai.agent.graph import get_aida_graph
|
| 15 |
from app.ai.agent.schemas import AgentResponse
|
|
|
|
| 16 |
|
| 17 |
logger = get_logger(__name__)
|
| 18 |
|
|
@@ -44,8 +49,13 @@ class AskBody(BaseModel):
|
|
| 44 |
# MAIN CHAT ENDPOINT - LANGGRAPH POWERED (FIXED)
|
| 45 |
# ============================================================
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
@router.post("/ask", response_model=AgentResponse)
|
| 48 |
-
|
|
|
|
| 49 |
"""
|
| 50 |
Main chat endpoint using LangGraph state machine.
|
| 51 |
|
|
@@ -58,19 +68,15 @@ async def ask_ai(body: AskBody) -> AgentResponse:
|
|
| 58 |
Flow:
|
| 59 |
1. Validate input
|
| 60 |
2. Build input dict
|
| 61 |
-
3.
|
| 62 |
-
4. Extract
|
| 63 |
-
5. Return to client
|
| 64 |
"""
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
|
| 69 |
-
# ============================================================
|
| 70 |
-
# STEP 1: Validate input
|
| 71 |
-
# ============================================================
|
| 72 |
-
|
| 73 |
-
if not body.message or not body.message.strip():
|
| 74 |
logger.warning("❌ Empty message received")
|
| 75 |
return AgentResponse(
|
| 76 |
success=False,
|
|
@@ -79,51 +85,76 @@ async def ask_ai(body: AskBody) -> AgentResponse:
|
|
| 79 |
error="Empty message",
|
| 80 |
)
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
|
| 116 |
# Only initialize history if starting new session
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
# ============================================================
|
| 126 |
-
# STEP
|
| 127 |
# ============================================================
|
| 128 |
|
| 129 |
try:
|
|
@@ -158,15 +189,21 @@ async def ask_ai(body: AskBody) -> AgentResponse:
|
|
| 158 |
try:
|
| 159 |
# ✅ CRITICAL FIX: Pass recursion_limit config to prevent infinite loops
|
| 160 |
# ✅ CRITICAL FIX: Pass thread_id for persistence
|
| 161 |
-
config = {
|
| 162 |
-
"recursion_limit": 50,
|
| 163 |
-
"configurable": {"thread_id": session_id}
|
| 164 |
-
}
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
# ✅ CRITICAL: final_state_dict is a DICT, not AgentState!
|
| 172 |
# Access with dict keys: ['key'], not .attribute
|
|
@@ -256,18 +293,24 @@ async def ask_ai(body: AskBody) -> AgentResponse:
|
|
| 256 |
|
| 257 |
logger.info("✅ Fallback response built", action=response.action)
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
return response
|
| 260 |
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
logger.error("❌ Chat endpoint critical error", exc_info=e)
|
| 265 |
-
return AgentResponse(
|
| 266 |
-
success=False,
|
| 267 |
-
text="An unexpected error occurred. Please try again.",
|
| 268 |
-
action="error",
|
| 269 |
-
error=str(e),
|
| 270 |
-
)
|
| 271 |
|
| 272 |
|
| 273 |
# ============================================================
|
|
|
|
| 3 |
AIDA Chat Endpoint - Brain-Based AI Agent
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 7 |
+
from app.middleware import limiter, AI_RATE_LIMIT
|
| 8 |
from pydantic import BaseModel
|
| 9 |
from typing import Optional, Dict, Any
|
| 10 |
from structlog import get_logger
|
| 11 |
from uuid import uuid4
|
| 12 |
from datetime import datetime
|
| 13 |
+
import hashlib
|
| 14 |
+
import json
|
| 15 |
+
import asyncio
|
| 16 |
from langgraph.types import Command
|
| 17 |
|
| 18 |
from app.ai.agent.graph import get_aida_graph
|
| 19 |
from app.ai.agent.schemas import AgentResponse
|
| 20 |
+
from app.ai.config import redis_client
|
| 21 |
|
| 22 |
logger = get_logger(__name__)
|
| 23 |
|
|
|
|
| 49 |
# MAIN CHAT ENDPOINT - LANGGRAPH POWERED (FIXED)
|
| 50 |
# ============================================================
|
| 51 |
|
| 52 |
+
# Concurrency Control: Max 5 concurrent AI requests
|
| 53 |
+
# This prevents the server from being overwhelmed by CPU-heavy processing
|
| 54 |
+
ai_semaphore = asyncio.Semaphore(5)
|
| 55 |
+
|
| 56 |
@router.post("/ask", response_model=AgentResponse)
|
| 57 |
+
@limiter.limit(AI_RATE_LIMIT)
|
| 58 |
+
async def ask_ai(request: Request, body: AskBody) -> AgentResponse:
|
| 59 |
"""
|
| 60 |
Main chat endpoint using LangGraph state machine.
|
| 61 |
|
|
|
|
| 68 |
Flow:
|
| 69 |
1. Validate input
|
| 70 |
2. Build input dict
|
| 71 |
+
3. Run graph.ainvoke() (Thread-safe)
|
| 72 |
+
4. Extract and clean response
|
|
|
|
| 73 |
"""
|
| 74 |
|
| 75 |
+
# ============================================================
|
| 76 |
+
# STEP 1: Validate input
|
| 77 |
+
# ============================================================
|
| 78 |
|
| 79 |
+
if not body.message or not body.message.strip():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
logger.warning("❌ Empty message received")
|
| 81 |
return AgentResponse(
|
| 82 |
success=False,
|
|
|
|
| 85 |
error="Empty message",
|
| 86 |
)
|
| 87 |
|
| 88 |
+
message = body.message.strip()
|
| 89 |
+
session_id = body.session_id or str(uuid4())
|
| 90 |
+
user_id = body.user_id or f"anonymous_{uuid4()}"
|
| 91 |
+
user_role = body.user_role or "renter"
|
| 92 |
+
|
| 93 |
+
logger.info(
|
| 94 |
+
"📋 User session info",
|
| 95 |
+
user_id=user_id,
|
| 96 |
+
session_id=session_id,
|
| 97 |
+
user_role=user_role,
|
| 98 |
+
message_len=len(message),
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# ============================================================
|
| 102 |
+
# STEP 2: Build input dict for graph
|
| 103 |
+
# ============================================================
|
| 104 |
+
# ✅ CRITICAL: Pass dict, not AgentState
|
| 105 |
+
|
| 106 |
+
input_dict = {
|
| 107 |
+
"user_id": user_id,
|
| 108 |
+
"session_id": session_id,
|
| 109 |
+
"user_role": user_role,
|
| 110 |
+
"user_name": body.user_name,
|
| 111 |
+
"user_location": body.user_location,
|
| 112 |
+
"last_user_message": message,
|
| 113 |
+
# "conversation_history": [], <-- REMOVED: Do not overwrite history!
|
| 114 |
+
"language_detected": "en",
|
| 115 |
+
"start_new_session": body.start_new_session or False,
|
| 116 |
+
"is_voice_message": body.is_voice_message or False, # Track voice input
|
| 117 |
+
"source": body.source or "default",
|
| 118 |
+
# Store reply_context in temp_data so brain can access it
|
| 119 |
+
"temp_data": {"reply_context": body.reply_context} if body.reply_context else {},
|
| 120 |
+
}
|
| 121 |
|
| 122 |
# Only initialize history if starting new session
|
| 123 |
+
# Only initialize history if starting new session
|
| 124 |
+
if body.start_new_session:
|
| 125 |
+
input_dict["conversation_history"] = []
|
| 126 |
+
input_dict["provided_fields"] = {}
|
| 127 |
+
input_dict["missing_required_fields"] = []
|
| 128 |
+
logger.info("🆕 Starting NEW session (clearing state)")
|
| 129 |
+
|
| 130 |
+
logger.info("📦 Input dict prepared", keys=list(input_dict.keys()))
|
| 131 |
|
| 132 |
+
# ============================================================
|
| 133 |
+
# STEP 3: Smart Caching Check (Cost Saver)
|
| 134 |
+
# ============================================================
|
| 135 |
+
|
| 136 |
+
# Generate cache key: ai_cache:{role}:{hash_of_message}
|
| 137 |
+
# Only cache if not a new session (context matters) and message is substantial (>5 chars)
|
| 138 |
+
cache_key = None
|
| 139 |
+
should_cache = False
|
| 140 |
+
|
| 141 |
+
if not body.start_new_session and len(body.message) > 5 and redis_client:
|
| 142 |
+
msg_hash = hashlib.md5(body.message.lower().strip().encode()).hexdigest()
|
| 143 |
+
cache_key = f"ai_cache:{user_role}:{msg_hash}"
|
| 144 |
+
should_cache = True
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
cached_data = await redis_client.get(cache_key)
|
| 148 |
+
if cached_data:
|
| 149 |
+
logger.info(f"⚡ CACHE HIT: Serving cached response for {cache_key}")
|
| 150 |
+
cached_response = json.loads(cached_data)
|
| 151 |
+
# Return as AgentResponse object
|
| 152 |
+
return AgentResponse(**cached_response)
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logger.warning(f"⚠️ Cache read failed: {e}")
|
| 155 |
|
| 156 |
# ============================================================
|
| 157 |
+
# STEP 4: Get graph and validate
|
| 158 |
# ============================================================
|
| 159 |
|
| 160 |
try:
|
|
|
|
| 189 |
try:
|
| 190 |
# ✅ CRITICAL FIX: Pass recursion_limit config to prevent infinite loops
|
| 191 |
# ✅ CRITICAL FIX: Pass thread_id for persistence
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
+
# CONCURRENCY CONTROL: Wait here if server is busy
|
| 194 |
+
if ai_semaphore.locked():
|
| 195 |
+
logger.warning(f"⚠️ AI Semaphore FULL/LOCKED - Request for {user_id} waiting...")
|
| 196 |
+
|
| 197 |
+
async with ai_semaphore:
|
| 198 |
+
config = {
|
| 199 |
+
"recursion_limit": 50,
|
| 200 |
+
"configurable": {"thread_id": session_id}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
final_state_dict = await graph.ainvoke(
|
| 204 |
+
input_dict,
|
| 205 |
+
config=config
|
| 206 |
+
)
|
| 207 |
|
| 208 |
# ✅ CRITICAL: final_state_dict is a DICT, not AgentState!
|
| 209 |
# Access with dict keys: ['key'], not .attribute
|
|
|
|
| 293 |
|
| 294 |
logger.info("✅ Fallback response built", action=response.action)
|
| 295 |
|
| 296 |
+
# CACHE WRITE (Task 2.2)
|
| 297 |
+
if should_cache and cache_key and response.success:
|
| 298 |
+
try:
|
| 299 |
+
# Cache for 1 hour
|
| 300 |
+
await redis_client.setex(
|
| 301 |
+
cache_key,
|
| 302 |
+
3600,
|
| 303 |
+
json.dumps(response.dict())
|
| 304 |
+
)
|
| 305 |
+
logger.info(f"💾 Response cached: {cache_key}")
|
| 306 |
+
except Exception as e:
|
| 307 |
+
logger.warning(f"⚠️ Cache write warning: {e}")
|
| 308 |
+
|
| 309 |
return response
|
| 310 |
|
| 311 |
+
|
| 312 |
+
# End of ask_ai
|
| 313 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
|
| 316 |
# ============================================================
|
app/database.py
CHANGED
|
@@ -19,7 +19,14 @@ db = DatabaseConnection()
|
|
| 19 |
async def connect_db():
|
| 20 |
"""Connect to MongoDB"""
|
| 21 |
try:
|
| 22 |
-
db.client = AsyncClient(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
db.database = db.client[settings.MONGODB_DATABASE]
|
| 24 |
|
| 25 |
# Test connection
|
|
|
|
| 19 |
async def connect_db():
|
| 20 |
"""Connect to MongoDB"""
|
| 21 |
try:
|
| 22 |
+
db.client = AsyncClient(
|
| 23 |
+
settings.MONGODB_URL,
|
| 24 |
+
maxPoolSize=100, # Max connections (Scale: ~300 per replica member)
|
| 25 |
+
minPoolSize=10, # Keep warm connections ready
|
| 26 |
+
maxIdleTimeMS=45000, # Close idle connections after 45s
|
| 27 |
+
waitQueueTimeoutMS=10000, # Fail fast if pool is exhausted (10s)
|
| 28 |
+
serverSelectionTimeoutMS=5000, # Fail fast if DB is down (5s)
|
| 29 |
+
)
|
| 30 |
db.database = db.client[settings.MONGODB_DATABASE]
|
| 31 |
|
| 32 |
# Test connection
|
app/middleware/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/middleware/__init__.py - Middleware Package
|
| 3 |
+
# ============================================================
|
| 4 |
+
|
| 5 |
+
from .rate_limiter import (
|
| 6 |
+
limiter,
|
| 7 |
+
get_limiter,
|
| 8 |
+
rate_limit_exceeded_handler,
|
| 9 |
+
AI_RATE_LIMIT,
|
| 10 |
+
AUTH_RATE_LIMIT,
|
| 11 |
+
SEARCH_RATE_LIMIT,
|
| 12 |
+
STANDARD_RATE_LIMIT,
|
| 13 |
+
WEBSOCKET_RATE_LIMIT,
|
| 14 |
+
HEAVY_RATE_LIMIT,
|
| 15 |
+
is_exempt,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
"limiter",
|
| 20 |
+
"get_limiter",
|
| 21 |
+
"rate_limit_exceeded_handler",
|
| 22 |
+
"AI_RATE_LIMIT",
|
| 23 |
+
"AUTH_RATE_LIMIT",
|
| 24 |
+
"SEARCH_RATE_LIMIT",
|
| 25 |
+
"STANDARD_RATE_LIMIT",
|
| 26 |
+
"WEBSOCKET_RATE_LIMIT",
|
| 27 |
+
"HEAVY_RATE_LIMIT",
|
| 28 |
+
"is_exempt",
|
| 29 |
+
]
|
app/middleware/rate_limiter.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/middleware/rate_limiter.py - API Rate Limiting
|
| 3 |
+
# ============================================================
|
| 4 |
+
#
|
| 5 |
+
# Production-grade rate limiting using slowapi with Redis backend
|
| 6 |
+
# for distributed rate limiting across multiple server instances.
|
| 7 |
+
#
|
| 8 |
+
# Rate Limits:
|
| 9 |
+
# - AI endpoints (/ai/*): 10 requests/minute (expensive LLM calls)
|
| 10 |
+
# - Auth endpoints (/api/auth/*): 20 requests/minute (security)
|
| 11 |
+
# - Standard API: 100 requests/minute
|
| 12 |
+
# - WebSocket connections: 10/minute per IP
|
| 13 |
+
# ============================================================
|
| 14 |
+
|
| 15 |
+
from slowapi import Limiter
|
| 16 |
+
from slowapi.util import get_remote_address
|
| 17 |
+
from slowapi.errors import RateLimitExceeded
|
| 18 |
+
from slowapi.middleware import SlowAPIMiddleware
|
| 19 |
+
from fastapi import Request
|
| 20 |
+
from fastapi.responses import JSONResponse
|
| 21 |
+
import logging
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# ============================================================
|
| 27 |
+
# Configuration
|
| 28 |
+
# ============================================================
|
| 29 |
+
|
| 30 |
+
# Use Redis for distributed rate limiting in production
|
| 31 |
+
# Falls back to in-memory for development
|
| 32 |
+
REDIS_URL = os.getenv("REDIS_URL", None)
|
| 33 |
+
|
| 34 |
+
def get_client_identifier(request: Request) -> str:
|
| 35 |
+
"""
|
| 36 |
+
Get a unique identifier for the client.
|
| 37 |
+
Priority:
|
| 38 |
+
1. Authenticated user ID (if available in request state)
|
| 39 |
+
2. X-Forwarded-For header (for proxied requests)
|
| 40 |
+
3. Client IP address
|
| 41 |
+
"""
|
| 42 |
+
# Try to get authenticated user ID
|
| 43 |
+
if hasattr(request.state, "user_id") and request.state.user_id:
|
| 44 |
+
return f"user:{request.state.user_id}"
|
| 45 |
+
|
| 46 |
+
# Check for forwarded IP (behind load balancer/proxy)
|
| 47 |
+
forwarded = request.headers.get("X-Forwarded-For")
|
| 48 |
+
if forwarded:
|
| 49 |
+
# X-Forwarded-For can contain multiple IPs, get the first one
|
| 50 |
+
return forwarded.split(",")[0].strip()
|
| 51 |
+
|
| 52 |
+
# Fall back to direct client IP
|
| 53 |
+
return get_remote_address(request)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ============================================================
|
| 57 |
+
# Limiter Instance
|
| 58 |
+
# ============================================================
|
| 59 |
+
|
| 60 |
+
# Configure storage backend
|
| 61 |
+
if REDIS_URL:
|
| 62 |
+
storage_uri = REDIS_URL
|
| 63 |
+
logger.info(f"Rate limiter using Redis backend")
|
| 64 |
+
else:
|
| 65 |
+
storage_uri = "memory://"
|
| 66 |
+
logger.warning("Rate limiter using in-memory backend (not suitable for production)")
|
| 67 |
+
|
| 68 |
+
limiter = Limiter(
|
| 69 |
+
key_func=get_client_identifier,
|
| 70 |
+
default_limits=["100/minute"], # Default for all endpoints
|
| 71 |
+
storage_uri=storage_uri,
|
| 72 |
+
strategy="fixed-window", # Simple and efficient
|
| 73 |
+
headers_enabled=True, # Include X-RateLimit-* headers in response
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ============================================================
|
| 78 |
+
# Custom Rate Limit Exceeded Handler
|
| 79 |
+
# ============================================================
|
| 80 |
+
|
| 81 |
+
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
| 82 |
+
"""
|
| 83 |
+
Custom handler for rate limit exceeded errors.
|
| 84 |
+
Returns a user-friendly JSON response with retry information.
|
| 85 |
+
"""
|
| 86 |
+
logger.warning(
|
| 87 |
+
f"Rate limit exceeded for {get_client_identifier(request)}: "
|
| 88 |
+
f"{request.method} {request.url.path}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Parse the retry-after from the exception
|
| 92 |
+
retry_after = getattr(exc, "retry_after", 60)
|
| 93 |
+
|
| 94 |
+
return JSONResponse(
|
| 95 |
+
status_code=429,
|
| 96 |
+
content={
|
| 97 |
+
"success": False,
|
| 98 |
+
"error_code": "RATE_LIMIT_EXCEEDED",
|
| 99 |
+
"message": "Too many requests. Please slow down.",
|
| 100 |
+
"retry_after_seconds": retry_after,
|
| 101 |
+
"detail": str(exc.detail) if hasattr(exc, "detail") else "Rate limit exceeded",
|
| 102 |
+
},
|
| 103 |
+
headers={
|
| 104 |
+
"Retry-After": str(retry_after),
|
| 105 |
+
"X-RateLimit-Limit": str(getattr(exc, "limit", "unknown")),
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ============================================================
|
| 111 |
+
# Rate Limit Decorators for Specific Endpoints
|
| 112 |
+
# ============================================================
|
| 113 |
+
|
| 114 |
+
# AI endpoints - expensive, limit strictly
|
| 115 |
+
AI_RATE_LIMIT = "10/minute"
|
| 116 |
+
|
| 117 |
+
# Auth endpoints - security-sensitive
|
| 118 |
+
AUTH_RATE_LIMIT = "20/minute"
|
| 119 |
+
|
| 120 |
+
# Search endpoints - moderately expensive
|
| 121 |
+
SEARCH_RATE_LIMIT = "30/minute"
|
| 122 |
+
|
| 123 |
+
# Standard API endpoints
|
| 124 |
+
STANDARD_RATE_LIMIT = "100/minute"
|
| 125 |
+
|
| 126 |
+
# WebSocket connections
|
| 127 |
+
WEBSOCKET_RATE_LIMIT = "10/minute"
|
| 128 |
+
|
| 129 |
+
# Heavy operations (file uploads, etc.)
|
| 130 |
+
HEAVY_RATE_LIMIT = "5/minute"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ============================================================
|
| 134 |
+
# Helper function to apply rate limiting
|
| 135 |
+
# ============================================================
|
| 136 |
+
|
| 137 |
+
def get_limiter():
|
| 138 |
+
"""Get the configured limiter instance."""
|
| 139 |
+
return limiter
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ============================================================
|
| 143 |
+
# Exempt paths (no rate limiting)
|
| 144 |
+
# ============================================================
|
| 145 |
+
|
| 146 |
+
EXEMPT_PATHS = {
|
| 147 |
+
"/health",
|
| 148 |
+
"/",
|
| 149 |
+
"/docs",
|
| 150 |
+
"/openapi.json",
|
| 151 |
+
"/redoc",
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
def is_exempt(path: str) -> bool:
|
| 155 |
+
"""Check if a path is exempt from rate limiting."""
|
| 156 |
+
return path in EXEMPT_PATHS
|
app/routes/auth.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
import logging
|
| 2 |
from fastapi import APIRouter, Header, HTTPException, status, Depends, Request
|
|
|
|
|
|
|
| 3 |
from app.schemas.auth import (
|
| 4 |
SignupDto,
|
| 5 |
VerifySignupOtpDto,
|
|
@@ -43,13 +45,13 @@ async def check_auth_rate_limit(identifier: str, operation: str, request: Reques
|
|
| 43 |
# ============================================================
|
| 44 |
|
| 45 |
@router.post("/signup", status_code=status.HTTP_200_OK)
|
|
|
|
| 46 |
async def signup(signup_dto: SignupDto, request: Request):
|
| 47 |
"""
|
| 48 |
Step 1: Initiate Signup
|
| 49 |
Create account and send OTP to email or phone
|
| 50 |
"""
|
| 51 |
identifier = signup_dto.email or signup_dto.phone
|
| 52 |
-
await check_auth_rate_limit(identifier, "signup", request)
|
| 53 |
logger.info("Signup request")
|
| 54 |
return await auth_service.signup(signup_dto)
|
| 55 |
|
|
@@ -68,12 +70,12 @@ async def verify_signup_otp(dto: VerifySignupOtpDto, request: Request):
|
|
| 68 |
# ============================================================
|
| 69 |
|
| 70 |
@router.post("/login", status_code=status.HTTP_200_OK)
|
|
|
|
| 71 |
async def login(login_dto: LoginDto, request: Request):
|
| 72 |
"""
|
| 73 |
User Login
|
| 74 |
Authenticate with email or phone and password. Returns JWT token.
|
| 75 |
"""
|
| 76 |
-
await check_auth_rate_limit(login_dto.identifier, "login", request)
|
| 77 |
logger.info(f"Login request: {login_dto.identifier}")
|
| 78 |
return await auth_service.login(login_dto)
|
| 79 |
|
|
|
|
| 1 |
import logging
|
| 2 |
from fastapi import APIRouter, Header, HTTPException, status, Depends, Request
|
| 3 |
+
from app.middleware import limiter, AUTH_RATE_LIMIT
|
| 4 |
+
|
| 5 |
from app.schemas.auth import (
|
| 6 |
SignupDto,
|
| 7 |
VerifySignupOtpDto,
|
|
|
|
| 45 |
# ============================================================
|
| 46 |
|
| 47 |
@router.post("/signup", status_code=status.HTTP_200_OK)
|
| 48 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 49 |
async def signup(signup_dto: SignupDto, request: Request):
|
| 50 |
"""
|
| 51 |
Step 1: Initiate Signup
|
| 52 |
Create account and send OTP to email or phone
|
| 53 |
"""
|
| 54 |
identifier = signup_dto.email or signup_dto.phone
|
|
|
|
| 55 |
logger.info("Signup request")
|
| 56 |
return await auth_service.signup(signup_dto)
|
| 57 |
|
|
|
|
| 70 |
# ============================================================
|
| 71 |
|
| 72 |
@router.post("/login", status_code=status.HTTP_200_OK)
|
| 73 |
+
@limiter.limit(AUTH_RATE_LIMIT)
|
| 74 |
async def login(login_dto: LoginDto, request: Request):
|
| 75 |
"""
|
| 76 |
User Login
|
| 77 |
Authenticate with email or phone and password. Returns JWT token.
|
| 78 |
"""
|
|
|
|
| 79 |
logger.info(f"Login request: {login_dto.identifier}")
|
| 80 |
return await auth_service.login(login_dto)
|
| 81 |
|
app/routes/websocket_chat.py
CHANGED
|
@@ -371,24 +371,77 @@ class ChatConnectionManager:
|
|
| 371 |
"""Get online status for multiple users"""
|
| 372 |
return {uid: self.is_user_online(uid) for uid in user_ids}
|
| 373 |
|
| 374 |
-
async def
|
| 375 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
if user_id not in self.user_connections:
|
| 377 |
return False
|
| 378 |
|
| 379 |
disconnected = set()
|
|
|
|
| 380 |
for websocket in self.user_connections[user_id]:
|
| 381 |
try:
|
| 382 |
await websocket.send_json(message)
|
|
|
|
| 383 |
except Exception as e:
|
| 384 |
logger.warning(f"[Chat WS] Error sending to {user_id}: {e}")
|
| 385 |
disconnected.add(websocket)
|
| 386 |
|
| 387 |
-
# Clean up disconnected
|
| 388 |
for ws in disconnected:
|
| 389 |
self.disconnect(ws)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
async def broadcast_to_conversation(
|
| 394 |
self,
|
|
@@ -399,17 +452,27 @@ class ChatConnectionManager:
|
|
| 399 |
message_id: str = None
|
| 400 |
):
|
| 401 |
"""
|
| 402 |
-
Send message to all participants in a conversation.
|
| 403 |
-
Tracks delivery - when recipient is online and receives it, updates delivered_to.
|
| 404 |
"""
|
|
|
|
| 405 |
delivered_users = []
|
| 406 |
-
|
| 407 |
for user_id in participants:
|
| 408 |
-
sent_ok = await self.
|
| 409 |
if sent_ok and user_id != sender_id:
|
| 410 |
delivered_users.append(user_id)
|
| 411 |
|
| 412 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
if message_id and delivered_users and sender_id:
|
| 414 |
try:
|
| 415 |
from app.database import get_db
|
|
@@ -421,7 +484,7 @@ class ChatConnectionManager:
|
|
| 421 |
{"$addToSet": {"delivered_to": {"$each": delivered_users}}}
|
| 422 |
)
|
| 423 |
|
| 424 |
-
# Notify sender
|
| 425 |
delivery_event = {
|
| 426 |
"action": "message_delivered",
|
| 427 |
"conversation_id": conversation_id,
|
|
@@ -430,7 +493,6 @@ class ChatConnectionManager:
|
|
| 430 |
}
|
| 431 |
await self.send_to_user(sender_id, delivery_event)
|
| 432 |
|
| 433 |
-
logger.info(f"[Chat WS] Message {message_id} delivered to {len(delivered_users)} users")
|
| 434 |
except Exception as e:
|
| 435 |
logger.warning(f"[Chat WS] Failed to track delivery: {e}")
|
| 436 |
|
|
|
|
| 371 |
"""Get online status for multiple users"""
|
| 372 |
return {uid: self.is_user_online(uid) for uid in user_ids}
|
| 373 |
|
| 374 |
+
async def initialize(self):
|
| 375 |
+
"""Initialize Redis Pub/Sub listener"""
|
| 376 |
+
try:
|
| 377 |
+
from app.services.redis_pubsub import redis_pubsub
|
| 378 |
+
await redis_pubsub.start_listening(self._handle_redis_message)
|
| 379 |
+
logger.info("[Chat WS] Redis Pub/Sub listener initialized")
|
| 380 |
+
except Exception as e:
|
| 381 |
+
logger.warning(f"[Chat WS] Failed to init Redis Pub/Sub: {e}")
|
| 382 |
+
|
| 383 |
+
async def _handle_redis_message(self, payload: dict):
|
| 384 |
+
"""Process messages received from other servers via Redis"""
|
| 385 |
+
from app.services.redis_pubsub import redis_pubsub
|
| 386 |
+
|
| 387 |
+
# Ignore messages from self
|
| 388 |
+
if payload.get("source_server") == id(redis_pubsub):
|
| 389 |
+
return
|
| 390 |
+
|
| 391 |
+
event_type = payload.get("type")
|
| 392 |
+
data = payload.get("data", {})
|
| 393 |
+
|
| 394 |
+
if event_type == "direct":
|
| 395 |
+
user_id = data.get("target_user")
|
| 396 |
+
message = data.get("message")
|
| 397 |
+
await self._send_local(user_id, message)
|
| 398 |
+
|
| 399 |
+
elif event_type == "broadcast":
|
| 400 |
+
participants = data.get("participants", [])
|
| 401 |
+
message = data.get("message")
|
| 402 |
+
sender_id = data.get("sender_id")
|
| 403 |
+
|
| 404 |
+
for user_id in participants:
|
| 405 |
+
# Don't send back to sender (they assume it sent ok)
|
| 406 |
+
if user_id != sender_id:
|
| 407 |
+
await self._send_local(user_id, message)
|
| 408 |
+
|
| 409 |
+
async def _send_local(self, user_id: str, message: dict) -> bool:
|
| 410 |
+
"""Send message ONLY to locally connected users"""
|
| 411 |
if user_id not in self.user_connections:
|
| 412 |
return False
|
| 413 |
|
| 414 |
disconnected = set()
|
| 415 |
+
sent = False
|
| 416 |
for websocket in self.user_connections[user_id]:
|
| 417 |
try:
|
| 418 |
await websocket.send_json(message)
|
| 419 |
+
sent = True
|
| 420 |
except Exception as e:
|
| 421 |
logger.warning(f"[Chat WS] Error sending to {user_id}: {e}")
|
| 422 |
disconnected.add(websocket)
|
| 423 |
|
|
|
|
| 424 |
for ws in disconnected:
|
| 425 |
self.disconnect(ws)
|
| 426 |
+
|
| 427 |
+
return sent
|
| 428 |
+
|
| 429 |
+
async def send_to_user(self, user_id: str, message: dict):
|
| 430 |
+
"""Send message to a user (local + distributed)"""
|
| 431 |
+
# 1. Try sending locally
|
| 432 |
+
sent_local = await self._send_local(user_id, message)
|
| 433 |
|
| 434 |
+
# 2. Publish to Redis for other servers
|
| 435 |
+
try:
|
| 436 |
+
from app.services.redis_pubsub import redis_pubsub
|
| 437 |
+
await redis_pubsub.publish("direct", {
|
| 438 |
+
"target_user": user_id,
|
| 439 |
+
"message": message
|
| 440 |
+
})
|
| 441 |
+
except Exception:
|
| 442 |
+
pass
|
| 443 |
+
|
| 444 |
+
return sent_local
|
| 445 |
|
| 446 |
async def broadcast_to_conversation(
|
| 447 |
self,
|
|
|
|
| 452 |
message_id: str = None
|
| 453 |
):
|
| 454 |
"""
|
| 455 |
+
Send message to all participants in a conversation (local + distributed).
|
|
|
|
| 456 |
"""
|
| 457 |
+
# 1. Send to all LOCAL participants
|
| 458 |
delivered_users = []
|
|
|
|
| 459 |
for user_id in participants:
|
| 460 |
+
sent_ok = await self._send_local(user_id, message)
|
| 461 |
if sent_ok and user_id != sender_id:
|
| 462 |
delivered_users.append(user_id)
|
| 463 |
|
| 464 |
+
# 2. Publish broadcast event to Redis for other servers
|
| 465 |
+
try:
|
| 466 |
+
from app.services.redis_pubsub import redis_pubsub
|
| 467 |
+
await redis_pubsub.publish("broadcast", {
|
| 468 |
+
"participants": participants,
|
| 469 |
+
"message": message,
|
| 470 |
+
"sender_id": sender_id
|
| 471 |
+
})
|
| 472 |
+
except Exception as e:
|
| 473 |
+
logger.warning(f"[Chat WS] Redis publish failed: {e}")
|
| 474 |
+
|
| 475 |
+
# 3. Track delivery (only for local users currently)
|
| 476 |
if message_id and delivered_users and sender_id:
|
| 477 |
try:
|
| 478 |
from app.database import get_db
|
|
|
|
| 484 |
{"$addToSet": {"delivered_to": {"$each": delivered_users}}}
|
| 485 |
)
|
| 486 |
|
| 487 |
+
# Notify sender
|
| 488 |
delivery_event = {
|
| 489 |
"action": "message_delivered",
|
| 490 |
"conversation_id": conversation_id,
|
|
|
|
| 493 |
}
|
| 494 |
await self.send_to_user(sender_id, delivery_event)
|
| 495 |
|
|
|
|
| 496 |
except Exception as e:
|
| 497 |
logger.warning(f"[Chat WS] Failed to track delivery: {e}")
|
| 498 |
|
app/services/conversation_parts/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .crud_mixin import ConversationCRUDMixin
|
| 2 |
+
from .message_mixin import ConversationMessageMixin
|
| 3 |
+
from .action_mixin import ConversationActionMixin
|
| 4 |
+
from .ai_mixin import ConversationAIMixin
|
app/services/conversation_parts/action_mixin.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from bson import ObjectId
|
| 4 |
+
from fastapi import HTTPException, status
|
| 5 |
+
|
| 6 |
+
from app.database import get_db
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class ConversationActionMixin:
|
| 11 |
+
"""
|
| 12 |
+
Handling actions on messages: Edit, Delete, Reactions, Clear Chat
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
async def edit_message(
|
| 16 |
+
self,
|
| 17 |
+
conversation_id: str,
|
| 18 |
+
message_id: str,
|
| 19 |
+
user_id: str,
|
| 20 |
+
new_content: str,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""
|
| 23 |
+
Edit a message content.
|
| 24 |
+
Only the sender can edit, within 24 hours, text messages only.
|
| 25 |
+
"""
|
| 26 |
+
db = await get_db()
|
| 27 |
+
|
| 28 |
+
# Validate IDs
|
| 29 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 30 |
+
raise HTTPException(
|
| 31 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 32 |
+
detail="Invalid ID format"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Get the message
|
| 36 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 37 |
+
if not message_doc:
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 40 |
+
detail="Message not found"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Verify sender
|
| 44 |
+
if message_doc.get("sender_id") != user_id:
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 47 |
+
detail="You can only edit your own messages"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Verify message is in the correct conversation
|
| 51 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 54 |
+
detail="Message does not belong to this conversation"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Only allow editing text messages
|
| 58 |
+
if message_doc.get("message_type") != "text":
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 61 |
+
detail="Only text messages can be edited"
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Check 15-minute edit window
|
| 65 |
+
created_at = message_doc.get("created_at")
|
| 66 |
+
if created_at:
|
| 67 |
+
minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
|
| 68 |
+
if minutes_since > 15:
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 71 |
+
detail="Edit window expired (15 minutes)"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Cannot edit deleted messages
|
| 75 |
+
if message_doc.get("is_deleted"):
|
| 76 |
+
raise HTTPException(
|
| 77 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 78 |
+
detail="Cannot edit a deleted message"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Update the message
|
| 82 |
+
now = datetime.utcnow()
|
| 83 |
+
await db.messages.update_one(
|
| 84 |
+
{"_id": ObjectId(message_id)},
|
| 85 |
+
{
|
| 86 |
+
"$set": {
|
| 87 |
+
"content": new_content.strip(),
|
| 88 |
+
"is_edited": True,
|
| 89 |
+
"edited_at": now,
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Broadcast message_edited event to all participants via WebSocket
|
| 95 |
+
try:
|
| 96 |
+
from app.routes.websocket_chat import chat_manager
|
| 97 |
+
|
| 98 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 99 |
+
if conversation:
|
| 100 |
+
edit_event = {
|
| 101 |
+
"action": "message_edited",
|
| 102 |
+
"conversation_id": conversation_id,
|
| 103 |
+
"message_id": message_id,
|
| 104 |
+
"new_content": new_content.strip(),
|
| 105 |
+
"edited_at": now.isoformat(),
|
| 106 |
+
"edited_by": user_id,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
await chat_manager.broadcast_to_conversation(
|
| 110 |
+
conversation_id,
|
| 111 |
+
conversation.get("participants", []),
|
| 112 |
+
edit_event
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
logger.info(f"Broadcasted message_edited event for message {message_id}")
|
| 116 |
+
except Exception as e:
|
| 117 |
+
logger.warning(f"Failed to broadcast message_edited event: {e}")
|
| 118 |
+
|
| 119 |
+
logger.info(f"Message {message_id} edited by {user_id}")
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"success": True,
|
| 123 |
+
"message_id": message_id,
|
| 124 |
+
"new_content": new_content.strip(),
|
| 125 |
+
"edited_at": now.isoformat(),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
async def delete_message(
|
| 129 |
+
self,
|
| 130 |
+
conversation_id: str,
|
| 131 |
+
message_id: str,
|
| 132 |
+
user_id: str,
|
| 133 |
+
delete_for: str = "me", # "everyone" or "me"
|
| 134 |
+
) -> dict:
|
| 135 |
+
"""
|
| 136 |
+
Delete a message.
|
| 137 |
+
- "everyone": Only sender, within 1 hour
|
| 138 |
+
- "me": Any participant
|
| 139 |
+
"""
|
| 140 |
+
db = await get_db()
|
| 141 |
+
|
| 142 |
+
# Validate IDs
|
| 143 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 146 |
+
detail="Invalid ID format"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Get the message
|
| 150 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 151 |
+
if not message_doc:
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 154 |
+
detail="Message not found"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Verify message is in the correct conversation
|
| 158 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 161 |
+
detail="Message does not belong to this conversation"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Verify user is a participant
|
| 165 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 166 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 167 |
+
raise HTTPException(
|
| 168 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 169 |
+
detail="You are not a participant in this conversation"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
now = datetime.utcnow()
|
| 173 |
+
|
| 174 |
+
if delete_for == "everyone":
|
| 175 |
+
# Only sender can delete for everyone
|
| 176 |
+
if message_doc.get("sender_id") != user_id:
|
| 177 |
+
raise HTTPException(
|
| 178 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 179 |
+
detail="Only the sender can delete for everyone"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Check 1-hour delete window
|
| 183 |
+
created_at = message_doc.get("created_at")
|
| 184 |
+
if created_at:
|
| 185 |
+
hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
|
| 186 |
+
if hours_since > 1:
|
| 187 |
+
raise HTTPException(
|
| 188 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 189 |
+
detail="Delete-for-everyone window expired (1 hour)"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Mark as deleted for everyone
|
| 193 |
+
await db.messages.update_one(
|
| 194 |
+
{"_id": ObjectId(message_id)},
|
| 195 |
+
{
|
| 196 |
+
"$set": {
|
| 197 |
+
"is_deleted": True,
|
| 198 |
+
"deleted_at": now,
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Broadcast message_deleted event to all participants via WebSocket
|
| 204 |
+
try:
|
| 205 |
+
from app.routes.websocket_chat import chat_manager
|
| 206 |
+
|
| 207 |
+
delete_event = {
|
| 208 |
+
"action": "message_deleted",
|
| 209 |
+
"conversation_id": conversation_id,
|
| 210 |
+
"message_id": message_id,
|
| 211 |
+
"deleted_for": "everyone",
|
| 212 |
+
"deleted_at": now.isoformat(),
|
| 213 |
+
"deleted_by": user_id,
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
await chat_manager.broadcast_to_conversation(
|
| 217 |
+
conversation_id,
|
| 218 |
+
conversation.get("participants", []),
|
| 219 |
+
delete_event
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
logger.info(f"Broadcasted message_deleted event for message {message_id}")
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.warning(f"Failed to broadcast message_deleted event: {e}")
|
| 225 |
+
|
| 226 |
+
logger.info(f"Message {message_id} deleted for everyone by {user_id}")
|
| 227 |
+
|
| 228 |
+
return {
|
| 229 |
+
"success": True,
|
| 230 |
+
"message_id": message_id,
|
| 231 |
+
"deleted_for": "everyone",
|
| 232 |
+
"deleted_at": now.isoformat(),
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
else: # delete_for == "me"
|
| 236 |
+
# Add user to deleted_for list
|
| 237 |
+
await db.messages.update_one(
|
| 238 |
+
{"_id": ObjectId(message_id)},
|
| 239 |
+
{"$addToSet": {"deleted_for": user_id}}
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
logger.info(f"Message {message_id} deleted for {user_id} only")
|
| 243 |
+
|
| 244 |
+
return {
|
| 245 |
+
"success": True,
|
| 246 |
+
"message_id": message_id,
|
| 247 |
+
"deleted_for": "me",
|
| 248 |
+
"deleted_at": now.isoformat(),
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
async def add_reaction(
|
| 252 |
+
self,
|
| 253 |
+
conversation_id: str,
|
| 254 |
+
message_id: str,
|
| 255 |
+
user_id: str,
|
| 256 |
+
emoji: str,
|
| 257 |
+
) -> dict:
|
| 258 |
+
"""Add an emoji reaction to a message."""
|
| 259 |
+
db = await get_db()
|
| 260 |
+
|
| 261 |
+
# Validate IDs
|
| 262 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 263 |
+
raise HTTPException(
|
| 264 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 265 |
+
detail="Invalid ID format"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Get the message
|
| 269 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 270 |
+
if not message_doc:
|
| 271 |
+
raise HTTPException(
|
| 272 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 273 |
+
detail="Message not found"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
# Verify message is in the correct conversation
|
| 277 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 278 |
+
raise HTTPException(
|
| 279 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 280 |
+
detail="Message does not belong to this conversation"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Verify user is a participant
|
| 284 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 285 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 286 |
+
raise HTTPException(
|
| 287 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 288 |
+
detail="You are not a participant in this conversation"
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# Cannot react to deleted messages
|
| 292 |
+
if message_doc.get("is_deleted"):
|
| 293 |
+
raise HTTPException(
|
| 294 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 295 |
+
detail="Cannot react to a deleted message"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Add reaction
|
| 299 |
+
await db.messages.update_one(
|
| 300 |
+
{"_id": ObjectId(message_id)},
|
| 301 |
+
{"$addToSet": {f"reactions.{emoji}": user_id}}
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Broadcast reaction_added event to all participants via WebSocket
|
| 305 |
+
try:
|
| 306 |
+
from app.routes.websocket_chat import chat_manager
|
| 307 |
+
|
| 308 |
+
reaction_event = {
|
| 309 |
+
"action": "reaction_added",
|
| 310 |
+
"conversation_id": conversation_id,
|
| 311 |
+
"message_id": message_id,
|
| 312 |
+
"emoji": emoji,
|
| 313 |
+
"user_id": user_id,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
await chat_manager.broadcast_to_conversation(
|
| 317 |
+
conversation_id,
|
| 318 |
+
conversation.get("participants", []),
|
| 319 |
+
reaction_event
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
logger.info(f"Broadcasted reaction_added event for message {message_id}")
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.warning(f"Failed to broadcast reaction_added event: {e}")
|
| 325 |
+
|
| 326 |
+
logger.info(f"Reaction {emoji} added to message {message_id} by {user_id}")
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
"success": True,
|
| 330 |
+
"message_id": message_id,
|
| 331 |
+
"emoji": emoji,
|
| 332 |
+
"user_id": user_id,
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
async def remove_reaction(
|
| 336 |
+
self,
|
| 337 |
+
conversation_id: str,
|
| 338 |
+
message_id: str,
|
| 339 |
+
user_id: str,
|
| 340 |
+
emoji: str,
|
| 341 |
+
) -> dict:
|
| 342 |
+
"""Remove an emoji reaction from a message."""
|
| 343 |
+
db = await get_db()
|
| 344 |
+
|
| 345 |
+
# Validate IDs
|
| 346 |
+
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 347 |
+
raise HTTPException(
|
| 348 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 349 |
+
detail="Invalid ID format"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
# Get the message
|
| 353 |
+
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 354 |
+
if not message_doc:
|
| 355 |
+
raise HTTPException(
|
| 356 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 357 |
+
detail="Message not found"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
# Verify message is in the correct conversation
|
| 361 |
+
if message_doc.get("conversation_id") != conversation_id:
|
| 362 |
+
raise HTTPException(
|
| 363 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 364 |
+
detail="Message does not belong to this conversation"
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
# Verify user is a participant
|
| 368 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 369 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 370 |
+
raise HTTPException(
|
| 371 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 372 |
+
detail="You are not a participant in this conversation"
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# Remove reaction
|
| 376 |
+
await db.messages.update_one(
|
| 377 |
+
{"_id": ObjectId(message_id)},
|
| 378 |
+
{"$pull": {f"reactions.{emoji}": user_id}}
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
# Clean up empty reaction arrays
|
| 382 |
+
updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 383 |
+
if updated_msg:
|
| 384 |
+
reactions = updated_msg.get("reactions", {})
|
| 385 |
+
if emoji in reactions and len(reactions[emoji]) == 0:
|
| 386 |
+
await db.messages.update_one(
|
| 387 |
+
{"_id": ObjectId(message_id)},
|
| 388 |
+
{"$unset": {f"reactions.{emoji}": ""}}
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
# Broadcast reaction_removed event to all participants via WebSocket
|
| 392 |
+
try:
|
| 393 |
+
from app.routes.websocket_chat import chat_manager
|
| 394 |
+
|
| 395 |
+
reaction_event = {
|
| 396 |
+
"action": "reaction_removed",
|
| 397 |
+
"conversation_id": conversation_id,
|
| 398 |
+
"message_id": message_id,
|
| 399 |
+
"emoji": emoji,
|
| 400 |
+
"user_id": user_id,
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
await chat_manager.broadcast_to_conversation(
|
| 404 |
+
conversation_id,
|
| 405 |
+
conversation.get("participants", []),
|
| 406 |
+
reaction_event
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
logger.info(f"Broadcasted reaction_removed event for message {message_id}")
|
| 410 |
+
except Exception as e:
|
| 411 |
+
logger.warning(f"Failed to broadcast reaction_removed event: {e}")
|
| 412 |
+
|
| 413 |
+
logger.info(f"Reaction {emoji} removed from message {message_id} by {user_id}")
|
| 414 |
+
|
| 415 |
+
return {
|
| 416 |
+
"success": True,
|
| 417 |
+
"message_id": message_id,
|
| 418 |
+
"emoji": emoji,
|
| 419 |
+
"user_id": user_id,
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
async def clear_chat(
|
| 423 |
+
self,
|
| 424 |
+
conversation_id: str,
|
| 425 |
+
user_id: str,
|
| 426 |
+
) -> dict:
|
| 427 |
+
"""
|
| 428 |
+
Clear all messages in a conversation for the current user only.
|
| 429 |
+
|
| 430 |
+
Stores a cleared_at timestamp on the conversation document.
|
| 431 |
+
Messages with created_at <= cleared_at won't be shown to this user,
|
| 432 |
+
even after logout/login or on a new device.
|
| 433 |
+
Other participants still see all messages.
|
| 434 |
+
"""
|
| 435 |
+
db = await get_db()
|
| 436 |
+
|
| 437 |
+
# Validate ID
|
| 438 |
+
if not ObjectId.is_valid(conversation_id):
|
| 439 |
+
raise HTTPException(
|
| 440 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 441 |
+
detail="Invalid conversation ID format"
|
| 442 |
+
)
|
| 443 |
+
|
| 444 |
+
# Verify user is a participant
|
| 445 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 446 |
+
if not conversation or user_id not in conversation.get("participants", []):
|
| 447 |
+
raise HTTPException(
|
| 448 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 449 |
+
detail="You are not a participant in this conversation"
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
now = datetime.utcnow()
|
| 453 |
+
|
| 454 |
+
# Store cleared_at timestamp on conversation for persistent filtering
|
| 455 |
+
# This is the key change - ensures clear persists across sessions/devices
|
| 456 |
+
await db.conversations.update_one(
|
| 457 |
+
{"_id": ObjectId(conversation_id)},
|
| 458 |
+
{"$set": {f"cleared_at.{user_id}": now}}
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
# Also mark existing messages with deleted_for (backwards compatibility)
|
| 462 |
+
# This provides immediate UI response and works with existing code
|
| 463 |
+
result = await db.messages.update_many(
|
| 464 |
+
{"conversation_id": conversation_id},
|
| 465 |
+
{"$addToSet": {"deleted_for": user_id}}
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
logger.info(f"Chat {conversation_id} cleared for {user_id} at {now.isoformat()} ({result.modified_count} messages)")
|
| 469 |
+
|
| 470 |
+
return {
|
| 471 |
+
"success": True,
|
| 472 |
+
"conversation_id": conversation_id,
|
| 473 |
+
"cleared_count": result.modified_count,
|
| 474 |
+
"cleared_at": now.isoformat(),
|
| 475 |
+
}
|
app/services/conversation_parts/ai_mixin.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import asyncio
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from bson import ObjectId
|
| 6 |
+
|
| 7 |
+
from app.models.message import Message
|
| 8 |
+
from app.routes.websocket_chat import chat_manager
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class ConversationAIMixin:
|
| 13 |
+
"""
|
| 14 |
+
Handling AI (AIDA) interactions
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
async def _process_aida_response(
|
| 18 |
+
self,
|
| 19 |
+
db,
|
| 20 |
+
conversation_id: str,
|
| 21 |
+
conversation: dict,
|
| 22 |
+
user_id: str,
|
| 23 |
+
user_message: str,
|
| 24 |
+
user_message_id: str,
|
| 25 |
+
reply_context: Optional[dict] = None,
|
| 26 |
+
audio_url: Optional[str] = None,
|
| 27 |
+
):
|
| 28 |
+
"""
|
| 29 |
+
Process user message through AIDA AI brain and send response.
|
| 30 |
+
Runs asynchronously so user sees their message immediately.
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
logger.info(f"Processing AIDA response for user {user_id}")
|
| 34 |
+
|
| 35 |
+
# 1. Handle Voice Transcription if needed
|
| 36 |
+
if audio_url:
|
| 37 |
+
try:
|
| 38 |
+
from app.services.voice_service import voice_service
|
| 39 |
+
logger.info(f"Transcribing voice message: {audio_url}")
|
| 40 |
+
transcript, lang = await voice_service.transcribe_audio(audio_url)
|
| 41 |
+
|
| 42 |
+
if transcript:
|
| 43 |
+
user_message = transcript
|
| 44 |
+
logger.info(f"Voice transcribed: '{user_message}' ({lang})")
|
| 45 |
+
|
| 46 |
+
# Optionally update the original message with transcript?
|
| 47 |
+
# For now, we just let AIDA know the content
|
| 48 |
+
else:
|
| 49 |
+
logger.warning("Empty transcription result")
|
| 50 |
+
user_message = "(Inaudible voice message)"
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error(f"Transcription failed: {e}")
|
| 54 |
+
# Don't crash, just let AIDA handle failure
|
| 55 |
+
user_message = "(Voice message could not be transcribed)"
|
| 56 |
+
|
| 57 |
+
# Import specialized DM brain
|
| 58 |
+
from app.ai.agent.dm_brain import DmBrain
|
| 59 |
+
|
| 60 |
+
# Build context message with reply info
|
| 61 |
+
context_message = user_message
|
| 62 |
+
if reply_context:
|
| 63 |
+
property_card = reply_context.get("property_card")
|
| 64 |
+
replied_content = reply_context.get("message_content", "")
|
| 65 |
+
metadata = reply_context.get("metadata", {})
|
| 66 |
+
alert_title = metadata.get("alert_title") if metadata else None
|
| 67 |
+
|
| 68 |
+
if property_card:
|
| 69 |
+
listing_id = property_card.get("listing_id")
|
| 70 |
+
listing_title = property_card.get("title")
|
| 71 |
+
listing_location = property_card.get("location")
|
| 72 |
+
|
| 73 |
+
context_message = f"""{user_message}
|
| 74 |
+
|
| 75 |
+
[System Context: User is replying to property listing:
|
| 76 |
+
- ID: {listing_id}
|
| 77 |
+
- Title: {listing_title}
|
| 78 |
+
- Location: {listing_location}
|
| 79 |
+
|
| 80 |
+
When user says "this", "it", "this property", they mean this listing.
|
| 81 |
+
If user says they found what they wanted, use delete_alert tool with location matching this listing.]"""
|
| 82 |
+
|
| 83 |
+
elif alert_title:
|
| 84 |
+
context_message = f"""{user_message}
|
| 85 |
+
|
| 86 |
+
[System Context: User is replying to alert notification for: "{alert_title}"
|
| 87 |
+
If user says they found what they wanted or wants to stop notifications, use delete_alert tool.]"""
|
| 88 |
+
|
| 89 |
+
elif replied_content:
|
| 90 |
+
context_message = f"""{user_message}
|
| 91 |
+
|
| 92 |
+
[System Context: User is replying to AIDA's previous message: "{replied_content}"]"""
|
| 93 |
+
|
| 94 |
+
# Call specialized DM brain (architecture separation)
|
| 95 |
+
brain = DmBrain()
|
| 96 |
+
result = await brain.process(
|
| 97 |
+
message=context_message,
|
| 98 |
+
user_id=user_id,
|
| 99 |
+
source="dm",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Extract response text and metadata
|
| 103 |
+
response_text = result.get("text", "I'm sorry, I couldn't process that. Please try again.")
|
| 104 |
+
response_metadata = result.get("metadata", {})
|
| 105 |
+
|
| 106 |
+
# Check for property cards or alert results
|
| 107 |
+
property_card = None
|
| 108 |
+
if response_metadata.get("property_card"):
|
| 109 |
+
property_card = response_metadata.get("property_card")
|
| 110 |
+
|
| 111 |
+
# ============================================================
|
| 112 |
+
# VOICE RESPONSE: If user sent a voice note, AIDA responds with voice too
|
| 113 |
+
# ============================================================
|
| 114 |
+
aida_audio_url = None
|
| 115 |
+
aida_audio_duration = None
|
| 116 |
+
message_type = "text" # Default to text
|
| 117 |
+
|
| 118 |
+
if audio_url:
|
| 119 |
+
# User sent voice, so AIDA should respond with voice
|
| 120 |
+
try:
|
| 121 |
+
from app.services.voice_service import voice_service
|
| 122 |
+
|
| 123 |
+
# Determine language from transcription or default to English
|
| 124 |
+
response_language = "en"
|
| 125 |
+
|
| 126 |
+
# Generate AIDA's voice response
|
| 127 |
+
logger.info(f"Generating AIDA voice response for DM...")
|
| 128 |
+
voice_result = await voice_service.generate_aida_voice_response(
|
| 129 |
+
text=response_text,
|
| 130 |
+
language=response_language
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
if voice_result:
|
| 134 |
+
aida_audio_url = voice_result.get("audio_url")
|
| 135 |
+
aida_audio_duration = voice_result.get("duration")
|
| 136 |
+
message_type = "voice" # Change message type to voice
|
| 137 |
+
logger.info(f"✅ AIDA voice response generated: {aida_audio_url}")
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Failed to generate AIDA voice response: {e}")
|
| 141 |
+
# Fall back to text response
|
| 142 |
+
message_type = "text"
|
| 143 |
+
|
| 144 |
+
# Send AIDA's response as a message in the conversation
|
| 145 |
+
aida_message_doc = Message.create_document(
|
| 146 |
+
conversation_id=conversation_id,
|
| 147 |
+
sender_id="AIDA_BOT",
|
| 148 |
+
sender_name="AIDA",
|
| 149 |
+
sender_avatar="https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 150 |
+
message_type=message_type,
|
| 151 |
+
content=response_text,
|
| 152 |
+
property_card=property_card,
|
| 153 |
+
replied_to_message_id=user_message_id if reply_context else None,
|
| 154 |
+
replied_to_content=user_message if reply_context else None,
|
| 155 |
+
replied_to_sender="User" if reply_context else None,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# Add voice-specific fields if this is a voice response
|
| 159 |
+
if aida_audio_url:
|
| 160 |
+
aida_message_doc["audio_url"] = aida_audio_url
|
| 161 |
+
aida_message_doc["audio_duration"] = aida_audio_duration
|
| 162 |
+
|
| 163 |
+
# Add metadata for rich content (alert results, etc.)
|
| 164 |
+
if response_metadata:
|
| 165 |
+
aida_message_doc["metadata"] = response_metadata
|
| 166 |
+
|
| 167 |
+
# Insert AIDA's message
|
| 168 |
+
aida_result = await db.messages.insert_one(aida_message_doc)
|
| 169 |
+
aida_message_id = str(aida_result.inserted_id)
|
| 170 |
+
aida_message_doc["_id"] = aida_result.inserted_id
|
| 171 |
+
|
| 172 |
+
logger.info(f"AIDA response {aida_message_id} sent in conversation {conversation_id}")
|
| 173 |
+
|
| 174 |
+
# Update conversation's last_message (AIDA's response)
|
| 175 |
+
update_data = {
|
| 176 |
+
"last_message": {
|
| 177 |
+
"text": response_text[:100] if response_text else "[AI Response]",
|
| 178 |
+
"sender_id": "AIDA_BOT",
|
| 179 |
+
"timestamp": aida_message_doc["created_at"],
|
| 180 |
+
},
|
| 181 |
+
"updated_at": datetime.utcnow(),
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
# Increment unread count for user (AIDA just sent a message)
|
| 185 |
+
update_data[f"unread_count.{user_id}"] = conversation.get("unread_count", {}).get(user_id, 0) + 1
|
| 186 |
+
|
| 187 |
+
await db.conversations.update_one(
|
| 188 |
+
{"_id": ObjectId(conversation_id)},
|
| 189 |
+
{"$set": update_data}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Broadcast AIDA's response via WebSocket
|
| 193 |
+
try:
|
| 194 |
+
formatted_message = Message.format_response(aida_message_doc)
|
| 195 |
+
broadcast_message = {
|
| 196 |
+
"action": "new_message",
|
| 197 |
+
"conversation_id": conversation_id,
|
| 198 |
+
"message": formatted_message,
|
| 199 |
+
}
|
| 200 |
+
await chat_manager.broadcast_to_conversation(
|
| 201 |
+
conversation_id,
|
| 202 |
+
conversation["participants"],
|
| 203 |
+
broadcast_message
|
| 204 |
+
)
|
| 205 |
+
logger.info(f"AIDA response broadcast via WebSocket")
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.warning(f"Failed to broadcast AIDA response: {e}")
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error(f"Error processing AIDA response: {e}")
|
| 211 |
+
# Optionally send error message to user
|
| 212 |
+
try:
|
| 213 |
+
error_message_doc = Message.create_document(
|
| 214 |
+
conversation_id=conversation_id,
|
| 215 |
+
sender_id="AIDA_BOT",
|
| 216 |
+
sender_name="AIDA",
|
| 217 |
+
sender_avatar="https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 218 |
+
message_type="text",
|
| 219 |
+
content="I'm having trouble processing that right now. Please try again in a moment. 😅",
|
| 220 |
+
)
|
| 221 |
+
await db.messages.insert_one(error_message_doc)
|
| 222 |
+
except:
|
| 223 |
+
pass
|
app/services/conversation_parts/crud_mixin.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Optional, List, Dict, Any, Tuple
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from fastapi import HTTPException, status
|
| 6 |
+
|
| 7 |
+
from app.database import get_db
|
| 8 |
+
from app.models.conversation import Conversation
|
| 9 |
+
from app.models.listing import Listing
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class ConversationCRUDMixin:
|
| 14 |
+
"""Basic CRUD operations for conversations"""
|
| 15 |
+
|
| 16 |
+
async def start_or_get_conversation(
|
| 17 |
+
self,
|
| 18 |
+
listing_id: str,
|
| 19 |
+
current_user_id: str,
|
| 20 |
+
initial_message: Optional[str] = None,
|
| 21 |
+
) -> dict:
|
| 22 |
+
"""
|
| 23 |
+
Start a new conversation or get existing one between two users.
|
| 24 |
+
"""
|
| 25 |
+
db = await get_db()
|
| 26 |
+
|
| 27 |
+
# 1. Validate listing exists and get owner
|
| 28 |
+
if not ObjectId.is_valid(listing_id):
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 31 |
+
detail="Invalid listing ID format"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
listing = await db.listings.find_one({"_id": ObjectId(listing_id)})
|
| 35 |
+
if not listing:
|
| 36 |
+
raise HTTPException(
|
| 37 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 38 |
+
detail="Listing not found"
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
owner_id = listing.get("user_id")
|
| 42 |
+
if not owner_id:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 45 |
+
detail="Listing has no owner"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# 2. Check if user is trying to message themselves
|
| 49 |
+
if owner_id == current_user_id:
|
| 50 |
+
return {
|
| 51 |
+
"success": False,
|
| 52 |
+
"error": "self_chat",
|
| 53 |
+
"message": "Oops! You can't chat with yourself. This is your own listing! 😊",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# 3. Prepare property card data (always needed for frontend prompt)
|
| 57 |
+
property_card = {
|
| 58 |
+
"listing_id": listing_id,
|
| 59 |
+
"title": listing.get("title", ""),
|
| 60 |
+
"price": listing.get("price", 0),
|
| 61 |
+
"currency": listing.get("currency", "NGN"),
|
| 62 |
+
"bedrooms": listing.get("bedrooms", 0),
|
| 63 |
+
"bathrooms": listing.get("bathrooms", 0),
|
| 64 |
+
"location": listing.get("location", ""),
|
| 65 |
+
"image_url": listing.get("images", [None])[0] if listing.get("images") else None,
|
| 66 |
+
"listing_type": listing.get("listing_type", ""),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# 4. Check if conversation already exists BETWEEN THESE TWO USERS (regardless of listing)
|
| 70 |
+
participants = sorted([owner_id, current_user_id]) # Sort for consistent ordering
|
| 71 |
+
participants_key = "::".join(participants)
|
| 72 |
+
|
| 73 |
+
existing_conversation = await db.conversations.find_one({
|
| 74 |
+
"participants_key": participants_key
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
if existing_conversation:
|
| 78 |
+
logger.info(f"Found existing conversation between users: {existing_conversation['_id']}")
|
| 79 |
+
return {
|
| 80 |
+
"success": True,
|
| 81 |
+
"is_new": False,
|
| 82 |
+
"conversation": Conversation.format_response(existing_conversation),
|
| 83 |
+
"property_card": property_card, # Always include so frontend can prompt to send
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# 5. Create new conversation (first time these two users chat)
|
| 87 |
+
conversation_doc = Conversation.create_document(
|
| 88 |
+
listing_id=listing_id, # Store the first listing that started the conversation
|
| 89 |
+
participants=participants,
|
| 90 |
+
listing_title=listing.get("title", "Property"),
|
| 91 |
+
listing_image=listing.get("images", [None])[0] if listing.get("images") else None,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
result = await db.conversations.insert_one(conversation_doc)
|
| 95 |
+
conversation_id = str(result.inserted_id)
|
| 96 |
+
|
| 97 |
+
logger.info(f"Created new conversation: {conversation_id}")
|
| 98 |
+
|
| 99 |
+
# 6. Get conversation with ID
|
| 100 |
+
conversation_doc["_id"] = result.inserted_id
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
"success": True,
|
| 104 |
+
"is_new": True,
|
| 105 |
+
"conversation": Conversation.format_response(conversation_doc),
|
| 106 |
+
"property_card": property_card, # Frontend will show confirmation dialog
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async def start_or_get_aida_conversation(
|
| 110 |
+
self,
|
| 111 |
+
current_user_id: str,
|
| 112 |
+
) -> dict:
|
| 113 |
+
"""
|
| 114 |
+
Start or get an AIDA DM conversation.
|
| 115 |
+
"""
|
| 116 |
+
db = await get_db()
|
| 117 |
+
|
| 118 |
+
AIDA_BOT_ID = "AIDA_BOT"
|
| 119 |
+
LEGACY_AIDA_ID = "ai_assistant" # Legacy ID that might exist in old conversations
|
| 120 |
+
|
| 121 |
+
# 1. Find existing conversation between AIDA and User
|
| 122 |
+
participants = sorted([AIDA_BOT_ID, current_user_id])
|
| 123 |
+
participants_key = "::".join(participants)
|
| 124 |
+
|
| 125 |
+
# First, try newer format with participants_key
|
| 126 |
+
existing_conversation = await db.conversations.find_one({
|
| 127 |
+
"participants_key": participants_key
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
# Fallback: Try legacy ai_assistant ID
|
| 131 |
+
if not existing_conversation:
|
| 132 |
+
legacy_participants = sorted([LEGACY_AIDA_ID, current_user_id])
|
| 133 |
+
legacy_key = "::".join(legacy_participants)
|
| 134 |
+
existing_conversation = await db.conversations.find_one({
|
| 135 |
+
"participants_key": legacy_key
|
| 136 |
+
})
|
| 137 |
+
if existing_conversation:
|
| 138 |
+
logger.info(f"Found legacy AIDA conversation with ai_assistant ID")
|
| 139 |
+
|
| 140 |
+
# Fallback: Try querying by participants array
|
| 141 |
+
if not existing_conversation:
|
| 142 |
+
existing_conversation = await db.conversations.find_one({
|
| 143 |
+
"participants": {"$all": [AIDA_BOT_ID, current_user_id]}
|
| 144 |
+
})
|
| 145 |
+
if existing_conversation:
|
| 146 |
+
logger.info(f"Found AIDA conversation by participants array")
|
| 147 |
+
|
| 148 |
+
# Fallback: Try legacy array format
|
| 149 |
+
if not existing_conversation:
|
| 150 |
+
existing_conversation = await db.conversations.find_one({
|
| 151 |
+
"participants": {"$all": [LEGACY_AIDA_ID, current_user_id]}
|
| 152 |
+
})
|
| 153 |
+
if existing_conversation:
|
| 154 |
+
logger.info(f"Found legacy AIDA conversation by participants array")
|
| 155 |
+
|
| 156 |
+
if existing_conversation:
|
| 157 |
+
conv_id = str(existing_conversation["_id"])
|
| 158 |
+
logger.info(f"Found existing AIDA conversation: {conv_id}")
|
| 159 |
+
|
| 160 |
+
# Determine which AIDA ID was used
|
| 161 |
+
aida_id = AIDA_BOT_ID if AIDA_BOT_ID in existing_conversation.get("participants", []) else LEGACY_AIDA_ID
|
| 162 |
+
actual_participants = [aida_id, current_user_id]
|
| 163 |
+
|
| 164 |
+
# Enrich with participants data
|
| 165 |
+
enriched_participants, other_participant = await self._enrich_participants(
|
| 166 |
+
db, actual_participants, current_user_id
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
conv_response = Conversation.format_response(existing_conversation)
|
| 170 |
+
conv_response["participants"] = enriched_participants
|
| 171 |
+
conv_response["other_participant"] = other_participant
|
| 172 |
+
|
| 173 |
+
return {
|
| 174 |
+
"success": True,
|
| 175 |
+
"is_new": False,
|
| 176 |
+
"conversation": conv_response,
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
# 2. Create new AIDA conversation
|
| 180 |
+
conversation_doc = Conversation.create_document(
|
| 181 |
+
listing_id="system", # Generic ID for system chats
|
| 182 |
+
participants=participants,
|
| 183 |
+
listing_title="AIDA Assistant",
|
| 184 |
+
listing_image=None
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
try:
|
| 188 |
+
result = await db.conversations.insert_one(conversation_doc)
|
| 189 |
+
conv_id = str(result.inserted_id)
|
| 190 |
+
conversation_doc["_id"] = result.inserted_id
|
| 191 |
+
logger.info(f"Created new AIDA conversation: {conv_id}")
|
| 192 |
+
except Exception as e:
|
| 193 |
+
error_str = str(e)
|
| 194 |
+
logger.error(f"Insert failed with error: {error_str}")
|
| 195 |
+
|
| 196 |
+
if "duplicate key" in error_str.lower() or "E11000" in error_str:
|
| 197 |
+
# Race condition fallback
|
| 198 |
+
existing_conversation = await db.conversations.find_one({
|
| 199 |
+
"participants": {"$all": [AIDA_BOT_ID, current_user_id]}
|
| 200 |
+
})
|
| 201 |
+
|
| 202 |
+
if existing_conversation:
|
| 203 |
+
enriched_participants, other_participant = await self._enrich_participants(
|
| 204 |
+
db, existing_conversation.get("participants", participants), current_user_id
|
| 205 |
+
)
|
| 206 |
+
conv_response = Conversation.format_response(existing_conversation)
|
| 207 |
+
conv_response["participants"] = enriched_participants
|
| 208 |
+
return {
|
| 209 |
+
"success": True,
|
| 210 |
+
"is_new": False,
|
| 211 |
+
"conversation": conv_response,
|
| 212 |
+
}
|
| 213 |
+
else:
|
| 214 |
+
raise
|
| 215 |
+
else:
|
| 216 |
+
raise
|
| 217 |
+
|
| 218 |
+
# 3. Enrich with participants data
|
| 219 |
+
enriched_participants, other_participant = await self._enrich_participants(
|
| 220 |
+
db, participants, current_user_id
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
conv_response = Conversation.format_response(conversation_doc)
|
| 224 |
+
conv_response["participants"] = enriched_participants
|
| 225 |
+
conv_response["other_participant"] = other_participant
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
"success": True,
|
| 229 |
+
"is_new": True,
|
| 230 |
+
"conversation": conv_response,
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
async def _enrich_participants(self, db, participant_ids: list[str], current_user_id: str = None) -> tuple[list[dict], dict]:
|
| 234 |
+
"""
|
| 235 |
+
Enrich participant IDs with full user data and online status.
|
| 236 |
+
Returns (all_participants, other_participant).
|
| 237 |
+
"""
|
| 238 |
+
from app.services.presence_service import presence_service
|
| 239 |
+
|
| 240 |
+
AIDA_BOT_ID = "AIDA_BOT"
|
| 241 |
+
AIDA_PROFILE = {
|
| 242 |
+
"id": AIDA_BOT_ID,
|
| 243 |
+
"name": "AIDA",
|
| 244 |
+
"profile_picture": "https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 245 |
+
"is_online": True, # AIDA is always online
|
| 246 |
+
"last_seen": None,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
participants = []
|
| 250 |
+
other_participant = None
|
| 251 |
+
|
| 252 |
+
# AIDA IDs to handle (both new and legacy)
|
| 253 |
+
AIDA_IDS = {AIDA_BOT_ID, "ai_assistant"}
|
| 254 |
+
|
| 255 |
+
# Batch fetch all users (skip AIDA IDs)
|
| 256 |
+
users_map = {}
|
| 257 |
+
real_user_ids = [uid for uid in participant_ids if uid not in AIDA_IDS]
|
| 258 |
+
|
| 259 |
+
for uid in real_user_ids:
|
| 260 |
+
if ObjectId.is_valid(uid):
|
| 261 |
+
user = await db.users.find_one({"_id": ObjectId(uid)})
|
| 262 |
+
if user:
|
| 263 |
+
users_map[uid] = user
|
| 264 |
+
|
| 265 |
+
# Get online statuses (only for real users)
|
| 266 |
+
online_statuses = await presence_service.get_bulk_status(real_user_ids) if real_user_ids else {}
|
| 267 |
+
|
| 268 |
+
# Build enriched participants
|
| 269 |
+
for uid in participant_ids:
|
| 270 |
+
# Handle AIDA (both AIDA_BOT and ai_assistant) specially
|
| 271 |
+
if uid in AIDA_IDS:
|
| 272 |
+
participant = AIDA_PROFILE.copy()
|
| 273 |
+
else:
|
| 274 |
+
user = users_map.get(uid)
|
| 275 |
+
status_data = online_statuses.get(uid, {"is_online": False, "last_seen": None})
|
| 276 |
+
|
| 277 |
+
participant = {
|
| 278 |
+
"id": uid,
|
| 279 |
+
"name": f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() if user else "Unknown",
|
| 280 |
+
"profile_picture": user.get("profilePicture") if user else None,
|
| 281 |
+
"is_online": status_data.get("is_online", False),
|
| 282 |
+
"last_seen": status_data.get("last_seen"),
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
participants.append(participant)
|
| 286 |
+
|
| 287 |
+
# Track the OTHER participant (not current user)
|
| 288 |
+
if current_user_id and uid != current_user_id:
|
| 289 |
+
other_participant = participant
|
| 290 |
+
|
| 291 |
+
return participants, other_participant
|
| 292 |
+
|
| 293 |
+
async def get_user_conversations(
|
| 294 |
+
self,
|
| 295 |
+
user_id: str,
|
| 296 |
+
) -> list[dict]:
|
| 297 |
+
"""
|
| 298 |
+
Get all conversations for a user with ENRICHED participant data.
|
| 299 |
+
OPTIMIZED: Uses batch fetching to eliminate N+1 queries.
|
| 300 |
+
"""
|
| 301 |
+
db = await get_db()
|
| 302 |
+
from app.services.presence_service import presence_service
|
| 303 |
+
|
| 304 |
+
# 1. Fetch all conversation docs (single query)
|
| 305 |
+
conversations_cursor = db.conversations.find({
|
| 306 |
+
"participants": user_id
|
| 307 |
+
}).sort("updated_at", -1)
|
| 308 |
+
|
| 309 |
+
conversations_docs = await conversations_cursor.to_list(100)
|
| 310 |
+
|
| 311 |
+
if not conversations_docs:
|
| 312 |
+
return []
|
| 313 |
+
|
| 314 |
+
# 2. Collect ALL distinct participant IDs
|
| 315 |
+
all_participant_ids = set()
|
| 316 |
+
for doc in conversations_docs:
|
| 317 |
+
all_participant_ids.update(doc.get("participants", []))
|
| 318 |
+
|
| 319 |
+
# Filter out AIDA bots and invalid IDs
|
| 320 |
+
AIDA_IDS = {"AIDA_BOT", "ai_assistant"}
|
| 321 |
+
real_user_ids = {uid for uid in all_participant_ids if uid not in AIDA_IDS and ObjectId.is_valid(uid)}
|
| 322 |
+
|
| 323 |
+
# 3. Batch fetch all User profiles (single query)
|
| 324 |
+
users = await db.users.find({
|
| 325 |
+
"_id": {"$in": [ObjectId(uid) for uid in real_user_ids]}
|
| 326 |
+
}).to_list(None)
|
| 327 |
+
|
| 328 |
+
users_map = {str(u["_id"]): u for u in users}
|
| 329 |
+
|
| 330 |
+
# 4. Batch fetch online statuses (single redis pipeline)
|
| 331 |
+
online_statuses = await presence_service.get_bulk_status(list(real_user_ids))
|
| 332 |
+
|
| 333 |
+
# 5. Build response objects
|
| 334 |
+
AIDA_PROFILE = {
|
| 335 |
+
"id": "AIDA_BOT",
|
| 336 |
+
"name": "AIDA",
|
| 337 |
+
"profile_picture": "https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 338 |
+
"is_online": True,
|
| 339 |
+
"last_seen": None,
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
conversations = []
|
| 343 |
+
for doc in conversations_docs:
|
| 344 |
+
conv_id = str(doc["_id"])
|
| 345 |
+
participant_ids = doc.get("participants", [])
|
| 346 |
+
|
| 347 |
+
enriched_participants = []
|
| 348 |
+
other_participant = None
|
| 349 |
+
|
| 350 |
+
for uid in participant_ids:
|
| 351 |
+
# Handle AIDA
|
| 352 |
+
if uid in AIDA_IDS:
|
| 353 |
+
participant = AIDA_PROFILE.copy()
|
| 354 |
+
else:
|
| 355 |
+
# Handle Real Users
|
| 356 |
+
user = users_map.get(uid)
|
| 357 |
+
status_data = online_statuses.get(uid, {"is_online": False, "last_seen": None})
|
| 358 |
+
|
| 359 |
+
participant = {
|
| 360 |
+
"id": uid,
|
| 361 |
+
"name": f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() if user else "Unknown",
|
| 362 |
+
"profile_picture": user.get("profilePicture") if user else None,
|
| 363 |
+
"is_online": status_data.get("is_online", False),
|
| 364 |
+
"last_seen": status_data.get("last_seen"),
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
enriched_participants.append(participant)
|
| 368 |
+
|
| 369 |
+
if uid != user_id:
|
| 370 |
+
other_participant = participant
|
| 371 |
+
|
| 372 |
+
# Get unread count
|
| 373 |
+
unread_counts = doc.get("unread_count", {})
|
| 374 |
+
user_unread = unread_counts.get(user_id, 0) if isinstance(unread_counts, dict) else 0
|
| 375 |
+
|
| 376 |
+
conversations.append({
|
| 377 |
+
"id": conv_id,
|
| 378 |
+
"listing_id": doc.get("listing_id", ""),
|
| 379 |
+
"listing_title": doc.get("listing_title", ""),
|
| 380 |
+
"listing_image": doc.get("listing_image"),
|
| 381 |
+
"participants": enriched_participants,
|
| 382 |
+
"other_participant": other_participant,
|
| 383 |
+
"last_message": doc.get("last_message", {}),
|
| 384 |
+
"unread_count": user_unread,
|
| 385 |
+
"status": doc.get("status", "active"),
|
| 386 |
+
"created_at": doc.get("created_at"),
|
| 387 |
+
"updated_at": doc.get("updated_at"),
|
| 388 |
+
})
|
| 389 |
+
|
| 390 |
+
logger.info(f"OPTIMIZED: Found {len(conversations)} conversations for {user_id}")
|
| 391 |
+
return conversations
|
| 392 |
+
|
| 393 |
+
async def get_conversation_by_id(
|
| 394 |
+
self,
|
| 395 |
+
conversation_id: str,
|
| 396 |
+
current_user_id: str,
|
| 397 |
+
include_messages: bool = False,
|
| 398 |
+
mark_as_read: bool = False,
|
| 399 |
+
message_limit: int = 50,
|
| 400 |
+
) -> dict:
|
| 401 |
+
"""
|
| 402 |
+
Get a single conversation with optional messages.
|
| 403 |
+
"""
|
| 404 |
+
db = await get_db()
|
| 405 |
+
|
| 406 |
+
# Validate conversation ID
|
| 407 |
+
if not ObjectId.is_valid(conversation_id):
|
| 408 |
+
raise HTTPException(
|
| 409 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 410 |
+
detail="Invalid conversation ID format"
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
# Fetch conversation
|
| 414 |
+
doc = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 415 |
+
if not doc:
|
| 416 |
+
raise HTTPException(
|
| 417 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 418 |
+
detail="Conversation not found"
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
# Verify user is participant
|
| 422 |
+
if current_user_id not in doc.get("participants", []):
|
| 423 |
+
raise HTTPException(
|
| 424 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 425 |
+
detail="You are not a participant in this conversation"
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
# Enrich participants
|
| 429 |
+
participant_ids = doc.get("participants", [])
|
| 430 |
+
enriched_participants, other_participant = await self._enrich_participants(
|
| 431 |
+
db, participant_ids, current_user_id=current_user_id
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Get unread count for current user
|
| 435 |
+
unread_counts = doc.get("unread_count", {})
|
| 436 |
+
user_unread = unread_counts.get(current_user_id, 0) if isinstance(unread_counts, dict) else 0
|
| 437 |
+
|
| 438 |
+
# Build conversation response
|
| 439 |
+
conversation_data = {
|
| 440 |
+
"id": str(doc["_id"]),
|
| 441 |
+
"listing_id": doc.get("listing_id", ""),
|
| 442 |
+
"listing_title": doc.get("listing_title", ""),
|
| 443 |
+
"listing_image": doc.get("listing_image"),
|
| 444 |
+
"participants": enriched_participants,
|
| 445 |
+
"other_participant": other_participant,
|
| 446 |
+
"last_message": doc.get("last_message", {}),
|
| 447 |
+
"unread_count": user_unread,
|
| 448 |
+
"status": doc.get("status", "active"),
|
| 449 |
+
"created_at": doc.get("created_at"),
|
| 450 |
+
"updated_at": doc.get("updated_at"),
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
result = {
|
| 454 |
+
"conversation": conversation_data,
|
| 455 |
+
"messages": [],
|
| 456 |
+
"has_more": False,
|
| 457 |
+
"total_messages": 0,
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
# Include messages if requested
|
| 461 |
+
if include_messages:
|
| 462 |
+
# Requires MessageService mixin
|
| 463 |
+
messages = await self.get_conversation_messages(
|
| 464 |
+
conversation_id=conversation_id,
|
| 465 |
+
current_user_id=current_user_id,
|
| 466 |
+
limit=message_limit,
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Count total messages
|
| 470 |
+
total_count = await db.messages.count_documents({
|
| 471 |
+
"conversation_id": conversation_id,
|
| 472 |
+
"deleted_for": {"$ne": current_user_id},
|
| 473 |
+
})
|
| 474 |
+
|
| 475 |
+
result["messages"] = messages
|
| 476 |
+
result["has_more"] = total_count > len(messages)
|
| 477 |
+
result["total_messages"] = total_count
|
| 478 |
+
|
| 479 |
+
# Mark as read if requested
|
| 480 |
+
if mark_as_read:
|
| 481 |
+
# Requires MessageService mixin
|
| 482 |
+
await self.mark_as_read(conversation_id, current_user_id)
|
| 483 |
+
result["conversation"]["unread_count"] = 0
|
| 484 |
+
|
| 485 |
+
return result
|
app/services/conversation_parts/message_mixin.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from bson import ObjectId
|
| 6 |
+
from fastapi import HTTPException, status
|
| 7 |
+
|
| 8 |
+
from app.database import get_db
|
| 9 |
+
from app.models.message import Message
|
| 10 |
+
from app.routes.websocket_chat import chat_manager
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class ConversationMessageMixin:
|
| 15 |
+
"""
|
| 16 |
+
Handling message operations: Send, Get, Mark Read
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
async def get_conversation_messages(
|
| 20 |
+
self,
|
| 21 |
+
conversation_id: str,
|
| 22 |
+
current_user_id: str,
|
| 23 |
+
limit: int = 50,
|
| 24 |
+
before_id: Optional[str] = None,
|
| 25 |
+
) -> list[dict]:
|
| 26 |
+
"""
|
| 27 |
+
Get messages for a conversation with pagination.
|
| 28 |
+
"""
|
| 29 |
+
db = await get_db()
|
| 30 |
+
|
| 31 |
+
# Validate conversation ID
|
| 32 |
+
if not ObjectId.is_valid(conversation_id):
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 35 |
+
detail="Invalid conversation ID format"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Verify user is participant
|
| 39 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 40 |
+
if not conversation:
|
| 41 |
+
raise HTTPException(
|
| 42 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 43 |
+
detail="Conversation not found"
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
if current_user_id not in conversation.get("participants", []):
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 49 |
+
detail="You are not a participant in this conversation"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Check if user has cleared the chat - get their cleared_at timestamp
|
| 53 |
+
user_cleared_at = conversation.get("cleared_at", {}).get(current_user_id)
|
| 54 |
+
|
| 55 |
+
# Build query - exclude messages deleted specifically for this user
|
| 56 |
+
query = {
|
| 57 |
+
"conversation_id": conversation_id,
|
| 58 |
+
"deleted_for": {"$ne": current_user_id}, # Exclude messages deleted for this user
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Filter out messages sent before user cleared the chat
|
| 62 |
+
# This ensures persistence across logout/login and new devices
|
| 63 |
+
if user_cleared_at:
|
| 64 |
+
query["created_at"] = {"$gt": user_cleared_at}
|
| 65 |
+
|
| 66 |
+
if before_id and ObjectId.is_valid(before_id):
|
| 67 |
+
if "created_at" in query:
|
| 68 |
+
# Combine with existing created_at filter
|
| 69 |
+
query["$and"] = [
|
| 70 |
+
{"created_at": query.pop("created_at")},
|
| 71 |
+
{"_id": {"$lt": ObjectId(before_id)}}
|
| 72 |
+
]
|
| 73 |
+
else:
|
| 74 |
+
query["_id"] = {"$lt": ObjectId(before_id)}
|
| 75 |
+
|
| 76 |
+
# Get messages - sort ascending (oldest first, newest last)
|
| 77 |
+
# This gives chronological order: oldest at top, newest at bottom
|
| 78 |
+
cursor = db.messages.find(query).sort("created_at", 1).limit(limit)
|
| 79 |
+
|
| 80 |
+
messages = []
|
| 81 |
+
async for doc in cursor:
|
| 82 |
+
# Pass user_id for proper filtering in format_response
|
| 83 |
+
formatted = Message.format_response(doc, for_user_id=current_user_id)
|
| 84 |
+
if formatted: # Only add if not None (handles edge cases)
|
| 85 |
+
messages.append(formatted)
|
| 86 |
+
|
| 87 |
+
logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
|
| 88 |
+
|
| 89 |
+
return messages
|
| 90 |
+
|
| 91 |
+
async def send_message(
|
| 92 |
+
self,
|
| 93 |
+
conversation_id: str,
|
| 94 |
+
current_user_id: str,
|
| 95 |
+
current_user_name: str,
|
| 96 |
+
current_user_avatar: Optional[str],
|
| 97 |
+
message_type: str,
|
| 98 |
+
content: Optional[str] = None,
|
| 99 |
+
media: Optional[dict] = None,
|
| 100 |
+
property_card: Optional[dict] = None,
|
| 101 |
+
replied_to_message_id: Optional[str] = None,
|
| 102 |
+
replied_to_content: Optional[str] = None,
|
| 103 |
+
replied_to_sender: Optional[str] = None,
|
| 104 |
+
) -> dict:
|
| 105 |
+
"""
|
| 106 |
+
Send a message in a conversation.
|
| 107 |
+
"""
|
| 108 |
+
db = await get_db()
|
| 109 |
+
|
| 110 |
+
# Validate conversation
|
| 111 |
+
if not ObjectId.is_valid(conversation_id):
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 114 |
+
detail="Invalid conversation ID format"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 118 |
+
if not conversation:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 121 |
+
detail="Conversation not found"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if current_user_id not in conversation.get("participants", []):
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 127 |
+
detail="You are not a participant in this conversation"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Auto-detect message type based on content
|
| 131 |
+
if property_card:
|
| 132 |
+
message_type = "property_inquiry" # Override type when property card is present
|
| 133 |
+
|
| 134 |
+
# Create message
|
| 135 |
+
message_doc = Message.create_document(
|
| 136 |
+
conversation_id=conversation_id,
|
| 137 |
+
sender_id=current_user_id,
|
| 138 |
+
sender_name=current_user_name,
|
| 139 |
+
sender_avatar=current_user_avatar,
|
| 140 |
+
message_type=message_type,
|
| 141 |
+
content=content,
|
| 142 |
+
media=media,
|
| 143 |
+
property_card=property_card,
|
| 144 |
+
replied_to_message_id=replied_to_message_id,
|
| 145 |
+
replied_to_content=replied_to_content,
|
| 146 |
+
replied_to_sender=replied_to_sender,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
result = await db.messages.insert_one(message_doc)
|
| 150 |
+
message_id = str(result.inserted_id)
|
| 151 |
+
|
| 152 |
+
# Update conversation's last_message and unread count
|
| 153 |
+
other_user_id = next(
|
| 154 |
+
(p for p in conversation["participants"] if p != current_user_id),
|
| 155 |
+
None
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
update_data = {
|
| 159 |
+
"last_message": {
|
| 160 |
+
"text": content or f"[{message_type}]",
|
| 161 |
+
"sender_id": current_user_id,
|
| 162 |
+
"timestamp": message_doc["created_at"],
|
| 163 |
+
},
|
| 164 |
+
"updated_at": datetime.utcnow(),
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
# Increment unread count for other user
|
| 168 |
+
if other_user_id:
|
| 169 |
+
update_data[f"unread_count.{other_user_id}"] = conversation.get("unread_count", {}).get(other_user_id, 0) + 1
|
| 170 |
+
|
| 171 |
+
await db.conversations.update_one(
|
| 172 |
+
{"_id": ObjectId(conversation_id)},
|
| 173 |
+
{"$set": update_data}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
logger.info(f"Message {message_id} sent in conversation {conversation_id}")
|
| 177 |
+
|
| 178 |
+
# Get message with ID
|
| 179 |
+
message_doc["_id"] = result.inserted_id
|
| 180 |
+
|
| 181 |
+
# Broadcast to all participants via WebSocket (for in-app notifications)
|
| 182 |
+
try:
|
| 183 |
+
formatted_message = Message.format_response(message_doc)
|
| 184 |
+
broadcast_message = {
|
| 185 |
+
"action": "new_message",
|
| 186 |
+
"conversation_id": conversation_id,
|
| 187 |
+
"message": formatted_message,
|
| 188 |
+
}
|
| 189 |
+
await chat_manager.broadcast_to_conversation(
|
| 190 |
+
conversation_id,
|
| 191 |
+
conversation["participants"],
|
| 192 |
+
broadcast_message
|
| 193 |
+
)
|
| 194 |
+
logger.info(f"WebSocket broadcast sent for message {message_id}")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
# Don't fail the request if WebSocket broadcast fails
|
| 197 |
+
logger.warning(f"Failed to broadcast message via WebSocket: {e}")
|
| 198 |
+
|
| 199 |
+
# NEW: Detect AIDA chat and route to AI brain
|
| 200 |
+
AIDA_IDS = {"AIDA_BOT", "ai_assistant"}
|
| 201 |
+
is_aida_chat = any(p in AIDA_IDS for p in conversation.get("participants", []))
|
| 202 |
+
|
| 203 |
+
# Process if AIDA chat, user is not AIDA, and message is TEXT or VOICE
|
| 204 |
+
# (check content for text, or media for voice)
|
| 205 |
+
should_process = (
|
| 206 |
+
is_aida_chat and
|
| 207 |
+
current_user_id not in AIDA_IDS and
|
| 208 |
+
(
|
| 209 |
+
(message_type == "text" and content) or
|
| 210 |
+
(message_type == "voice" and media and media.get("url"))
|
| 211 |
+
)
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
if should_process:
|
| 215 |
+
try:
|
| 216 |
+
logger.info(f"AIDA DM detected (type={message_type}), routing to AI brain...")
|
| 217 |
+
|
| 218 |
+
# Extract audio URL for voice messages
|
| 219 |
+
audio_url = media.get("url") if message_type == "voice" and media else None
|
| 220 |
+
|
| 221 |
+
# Build reply context if replying to a message
|
| 222 |
+
reply_context = None
|
| 223 |
+
if replied_to_message_id:
|
| 224 |
+
replied_msg = await db.messages.find_one({"_id": ObjectId(replied_to_message_id)})
|
| 225 |
+
if replied_msg:
|
| 226 |
+
reply_context = {
|
| 227 |
+
"message_id": replied_to_message_id,
|
| 228 |
+
"message_content": replied_msg.get("content"),
|
| 229 |
+
"property_card": replied_msg.get("property_card"),
|
| 230 |
+
"metadata": replied_msg.get("metadata"),
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
# Call AIDA brain (async, don't block response) (Assumes method exists on self)
|
| 234 |
+
asyncio.create_task(self._process_aida_response(
|
| 235 |
+
db=db,
|
| 236 |
+
conversation_id=conversation_id,
|
| 237 |
+
conversation=conversation,
|
| 238 |
+
user_id=current_user_id,
|
| 239 |
+
user_message=content,
|
| 240 |
+
user_message_id=message_id,
|
| 241 |
+
reply_context=reply_context,
|
| 242 |
+
audio_url=audio_url,
|
| 243 |
+
))
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Failed to route to AIDA brain: {e}")
|
| 247 |
+
|
| 248 |
+
return {
|
| 249 |
+
"success": True,
|
| 250 |
+
"message": Message.format_response(message_doc),
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
async def mark_as_read(
|
| 254 |
+
self,
|
| 255 |
+
conversation_id: str,
|
| 256 |
+
current_user_id: str,
|
| 257 |
+
) -> dict:
|
| 258 |
+
"""
|
| 259 |
+
Mark all messages in a conversation as read for the current user.
|
| 260 |
+
"""
|
| 261 |
+
db = await get_db()
|
| 262 |
+
|
| 263 |
+
# Validate conversation
|
| 264 |
+
if not ObjectId.is_valid(conversation_id):
|
| 265 |
+
raise HTTPException(
|
| 266 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 267 |
+
detail="Invalid conversation ID format"
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 271 |
+
if not conversation:
|
| 272 |
+
raise HTTPException(
|
| 273 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 274 |
+
detail="Conversation not found"
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
if current_user_id not in conversation.get("participants", []):
|
| 278 |
+
raise HTTPException(
|
| 279 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 280 |
+
detail="You are not a participant in this conversation"
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# Mark messages as read and add user to read_by array
|
| 284 |
+
now = datetime.utcnow()
|
| 285 |
+
result = await db.messages.update_many(
|
| 286 |
+
{
|
| 287 |
+
"conversation_id": conversation_id,
|
| 288 |
+
"sender_id": {"$ne": current_user_id}, # Not sent by this user
|
| 289 |
+
"read_by": {"$ne": current_user_id}, # Not already read by this user
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"$set": {
|
| 293 |
+
"is_read": True, # Keep for backwards compatibility
|
| 294 |
+
"read_at": now,
|
| 295 |
+
},
|
| 296 |
+
"$addToSet": {
|
| 297 |
+
"read_by": current_user_id # Add user to read_by array (no duplicates)
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
logger.info(f"Marked {result.modified_count} messages as read, added {current_user_id} to read_by")
|
| 303 |
+
|
| 304 |
+
# Reset unread count for current user
|
| 305 |
+
await db.conversations.update_one(
|
| 306 |
+
{"_id": ObjectId(conversation_id)},
|
| 307 |
+
{"$set": {f"unread_count.{current_user_id}": 0}}
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# Broadcast message_read event to other participants via WebSocket
|
| 311 |
+
try:
|
| 312 |
+
read_event = {
|
| 313 |
+
"action": "message_read",
|
| 314 |
+
"conversation_id": conversation_id,
|
| 315 |
+
"read_by": current_user_id,
|
| 316 |
+
"read_at": now.isoformat(),
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
# Send to other participants
|
| 320 |
+
for participant_id in conversation.get("participants", []):
|
| 321 |
+
if participant_id != current_user_id:
|
| 322 |
+
await chat_manager.send_to_user(participant_id, read_event)
|
| 323 |
+
|
| 324 |
+
logger.info(f"Broadcasted message_read event for conversation {conversation_id}")
|
| 325 |
+
except Exception as e:
|
| 326 |
+
# Don't fail the request if WebSocket broadcast fails
|
| 327 |
+
logger.warning(f"Failed to broadcast message_read event: {e}")
|
| 328 |
+
|
| 329 |
+
logger.info(f"Marked messages as read in conversation {conversation_id} for user {current_user_id}")
|
| 330 |
+
|
| 331 |
+
return {
|
| 332 |
+
"success": True,
|
| 333 |
+
"message": "Messages marked as read",
|
| 334 |
+
}
|
app/services/conversation_service.py
CHANGED
|
@@ -3,1526 +3,32 @@
|
|
| 3 |
# ============================================================
|
| 4 |
|
| 5 |
import logging
|
| 6 |
-
from datetime import datetime
|
| 7 |
-
from typing import Optional
|
| 8 |
-
from bson import ObjectId
|
| 9 |
-
from fastapi import HTTPException, status
|
| 10 |
|
| 11 |
-
from app.
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
- Always return property_card so frontend can ask to send inquiry
|
| 36 |
-
- Property inquiry cards can be sent for any listing in the same conversation
|
| 37 |
-
"""
|
| 38 |
-
db = await get_db()
|
| 39 |
-
|
| 40 |
-
# 1. Validate listing exists and get owner
|
| 41 |
-
if not ObjectId.is_valid(listing_id):
|
| 42 |
-
raise HTTPException(
|
| 43 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 44 |
-
detail="Invalid listing ID format"
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
listing = await db.listings.find_one({"_id": ObjectId(listing_id)})
|
| 48 |
-
if not listing:
|
| 49 |
-
raise HTTPException(
|
| 50 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 51 |
-
detail="Listing not found"
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
owner_id = listing.get("user_id")
|
| 55 |
-
if not owner_id:
|
| 56 |
-
raise HTTPException(
|
| 57 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 58 |
-
detail="Listing has no owner"
|
| 59 |
-
)
|
| 60 |
-
|
| 61 |
-
# 2. Check if user is trying to message themselves
|
| 62 |
-
if owner_id == current_user_id:
|
| 63 |
-
return {
|
| 64 |
-
"success": False,
|
| 65 |
-
"error": "self_chat",
|
| 66 |
-
"message": "Oops! You can't chat with yourself. This is your own listing! 😊",
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
# 3. Prepare property card data (always needed for frontend prompt)
|
| 70 |
-
property_card = {
|
| 71 |
-
"listing_id": listing_id,
|
| 72 |
-
"title": listing.get("title", ""),
|
| 73 |
-
"price": listing.get("price", 0),
|
| 74 |
-
"currency": listing.get("currency", "NGN"),
|
| 75 |
-
"bedrooms": listing.get("bedrooms", 0),
|
| 76 |
-
"bathrooms": listing.get("bathrooms", 0),
|
| 77 |
-
"location": listing.get("location", ""),
|
| 78 |
-
"image_url": listing.get("images", [None])[0] if listing.get("images") else None,
|
| 79 |
-
"listing_type": listing.get("listing_type", ""),
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
# 4. Check if conversation already exists BETWEEN THESE TWO USERS (regardless of listing)
|
| 83 |
-
participants = sorted([owner_id, current_user_id]) # Sort for consistent ordering
|
| 84 |
-
participants_key = "::".join(participants)
|
| 85 |
-
|
| 86 |
-
existing_conversation = await db.conversations.find_one({
|
| 87 |
-
"participants_key": participants_key
|
| 88 |
-
})
|
| 89 |
-
|
| 90 |
-
if existing_conversation:
|
| 91 |
-
logger.info(f"Found existing conversation between users: {existing_conversation['_id']}")
|
| 92 |
-
return {
|
| 93 |
-
"success": True,
|
| 94 |
-
"is_new": False,
|
| 95 |
-
"conversation": Conversation.format_response(existing_conversation),
|
| 96 |
-
"property_card": property_card, # Always include so frontend can prompt to send
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
# 5. Create new conversation (first time these two users chat)
|
| 100 |
-
conversation_doc = Conversation.create_document(
|
| 101 |
-
listing_id=listing_id, # Store the first listing that started the conversation
|
| 102 |
-
participants=participants,
|
| 103 |
-
listing_title=listing.get("title", "Property"),
|
| 104 |
-
listing_image=listing.get("images", [None])[0] if listing.get("images") else None,
|
| 105 |
-
)
|
| 106 |
-
|
| 107 |
-
result = await db.conversations.insert_one(conversation_doc)
|
| 108 |
-
conversation_id = str(result.inserted_id)
|
| 109 |
-
|
| 110 |
-
logger.info(f"Created new conversation: {conversation_id}")
|
| 111 |
-
|
| 112 |
-
# 6. Get conversation with ID
|
| 113 |
-
conversation_doc["_id"] = result.inserted_id
|
| 114 |
-
|
| 115 |
-
return {
|
| 116 |
-
"success": True,
|
| 117 |
-
"is_new": True,
|
| 118 |
-
"conversation": Conversation.format_response(conversation_doc),
|
| 119 |
-
"property_card": property_card, # Frontend will show confirmation dialog
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
async def start_or_get_aida_conversation(
|
| 123 |
-
self,
|
| 124 |
-
current_user_id: str,
|
| 125 |
-
) -> dict:
|
| 126 |
-
"""
|
| 127 |
-
Start or get an AIDA DM conversation.
|
| 128 |
-
|
| 129 |
-
This is used for:
|
| 130 |
-
- Opening the AIDA DM from the floating button
|
| 131 |
-
- Sending alert notifications to users
|
| 132 |
-
|
| 133 |
-
Uses the same logic as alert_service.py for consistency.
|
| 134 |
-
"""
|
| 135 |
-
db = await get_db()
|
| 136 |
-
|
| 137 |
-
AIDA_BOT_ID = "AIDA_BOT"
|
| 138 |
-
LEGACY_AIDA_ID = "ai_assistant" # Legacy ID that might exist in old conversations
|
| 139 |
-
|
| 140 |
-
# 1. Find existing conversation between AIDA and User
|
| 141 |
-
# Try multiple formats for backwards compatibility
|
| 142 |
-
participants = sorted([AIDA_BOT_ID, current_user_id])
|
| 143 |
-
participants_key = "::".join(participants)
|
| 144 |
-
|
| 145 |
-
# First, try newer format with participants_key
|
| 146 |
-
existing_conversation = await db.conversations.find_one({
|
| 147 |
-
"participants_key": participants_key
|
| 148 |
-
})
|
| 149 |
-
|
| 150 |
-
# Fallback: Try legacy ai_assistant ID
|
| 151 |
-
if not existing_conversation:
|
| 152 |
-
legacy_participants = sorted([LEGACY_AIDA_ID, current_user_id])
|
| 153 |
-
legacy_key = "::".join(legacy_participants)
|
| 154 |
-
existing_conversation = await db.conversations.find_one({
|
| 155 |
-
"participants_key": legacy_key
|
| 156 |
-
})
|
| 157 |
-
if existing_conversation:
|
| 158 |
-
logger.info(f"Found legacy AIDA conversation with ai_assistant ID")
|
| 159 |
-
|
| 160 |
-
# Fallback: Try querying by participants array
|
| 161 |
-
if not existing_conversation:
|
| 162 |
-
existing_conversation = await db.conversations.find_one({
|
| 163 |
-
"participants": {"$all": [AIDA_BOT_ID, current_user_id]}
|
| 164 |
-
})
|
| 165 |
-
if existing_conversation:
|
| 166 |
-
logger.info(f"Found AIDA conversation by participants array")
|
| 167 |
-
|
| 168 |
-
# Fallback: Try legacy array format
|
| 169 |
-
if not existing_conversation:
|
| 170 |
-
existing_conversation = await db.conversations.find_one({
|
| 171 |
-
"participants": {"$all": [LEGACY_AIDA_ID, current_user_id]}
|
| 172 |
-
})
|
| 173 |
-
if existing_conversation:
|
| 174 |
-
logger.info(f"Found legacy AIDA conversation by participants array")
|
| 175 |
-
|
| 176 |
-
if existing_conversation:
|
| 177 |
-
conv_id = str(existing_conversation["_id"])
|
| 178 |
-
logger.info(f"Found existing AIDA conversation: {conv_id}")
|
| 179 |
-
|
| 180 |
-
# Determine which AIDA ID was used
|
| 181 |
-
aida_id = AIDA_BOT_ID if AIDA_BOT_ID in existing_conversation.get("participants", []) else LEGACY_AIDA_ID
|
| 182 |
-
actual_participants = [aida_id, current_user_id]
|
| 183 |
-
|
| 184 |
-
# Enrich with participants data
|
| 185 |
-
enriched_participants, other_participant = await self._enrich_participants(
|
| 186 |
-
db, actual_participants, current_user_id
|
| 187 |
-
)
|
| 188 |
-
|
| 189 |
-
# Format response
|
| 190 |
-
conv_response = Conversation.format_response(existing_conversation)
|
| 191 |
-
conv_response["participants"] = enriched_participants
|
| 192 |
-
conv_response["other_participant"] = other_participant
|
| 193 |
-
|
| 194 |
-
return {
|
| 195 |
-
"success": True,
|
| 196 |
-
"is_new": False,
|
| 197 |
-
"conversation": conv_response,
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
# 2. Create new AIDA conversation
|
| 201 |
-
conversation_doc = Conversation.create_document(
|
| 202 |
-
listing_id="system", # Generic ID for system chats
|
| 203 |
-
participants=participants,
|
| 204 |
-
listing_title="AIDA Assistant",
|
| 205 |
-
listing_image=None
|
| 206 |
-
)
|
| 207 |
-
|
| 208 |
-
try:
|
| 209 |
-
result = await db.conversations.insert_one(conversation_doc)
|
| 210 |
-
conv_id = str(result.inserted_id)
|
| 211 |
-
conversation_doc["_id"] = result.inserted_id
|
| 212 |
-
logger.info(f"Created new AIDA conversation: {conv_id}")
|
| 213 |
-
except Exception as e:
|
| 214 |
-
error_str = str(e)
|
| 215 |
-
logger.error(f"Insert failed with error: {error_str}")
|
| 216 |
-
|
| 217 |
-
# Handle race condition - conversation was created between check and insert
|
| 218 |
-
if "duplicate key" in error_str.lower() or "E11000" in error_str:
|
| 219 |
-
logger.warning(f"Conversation already exists (race condition), trying all query methods...")
|
| 220 |
-
|
| 221 |
-
# Try all possible query methods to find the existing conversation
|
| 222 |
-
existing_conversation = None
|
| 223 |
-
|
| 224 |
-
# Method 1: by participants_key
|
| 225 |
-
existing_conversation = await db.conversations.find_one({
|
| 226 |
-
"participants_key": participants_key
|
| 227 |
-
})
|
| 228 |
-
if existing_conversation:
|
| 229 |
-
logger.info("Found by participants_key after race")
|
| 230 |
-
|
| 231 |
-
# Method 2: by legacy participants_key
|
| 232 |
-
if not existing_conversation:
|
| 233 |
-
legacy_key = "::".join(sorted([LEGACY_AIDA_ID, current_user_id]))
|
| 234 |
-
existing_conversation = await db.conversations.find_one({
|
| 235 |
-
"participants_key": legacy_key
|
| 236 |
-
})
|
| 237 |
-
if existing_conversation:
|
| 238 |
-
logger.info("Found by legacy participants_key after race")
|
| 239 |
-
|
| 240 |
-
# Method 3: by participants array (AIDA_BOT)
|
| 241 |
-
if not existing_conversation:
|
| 242 |
-
existing_conversation = await db.conversations.find_one({
|
| 243 |
-
"participants": {"$all": [AIDA_BOT_ID, current_user_id]}
|
| 244 |
-
})
|
| 245 |
-
if existing_conversation:
|
| 246 |
-
logger.info("Found by participants array after race")
|
| 247 |
-
|
| 248 |
-
# Method 4: by participants array (ai_assistant)
|
| 249 |
-
if not existing_conversation:
|
| 250 |
-
existing_conversation = await db.conversations.find_one({
|
| 251 |
-
"participants": {"$all": [LEGACY_AIDA_ID, current_user_id]}
|
| 252 |
-
})
|
| 253 |
-
if existing_conversation:
|
| 254 |
-
logger.info("Found by legacy participants array after race")
|
| 255 |
-
|
| 256 |
-
# Method 5: Any conversation with this user involving AIDA-like participants
|
| 257 |
-
if not existing_conversation:
|
| 258 |
-
existing_conversation = await db.conversations.find_one({
|
| 259 |
-
"$and": [
|
| 260 |
-
{"participants": current_user_id},
|
| 261 |
-
{"$or": [
|
| 262 |
-
{"participants": AIDA_BOT_ID},
|
| 263 |
-
{"participants": LEGACY_AIDA_ID},
|
| 264 |
-
{"listing_title": "AIDA Assistant"},
|
| 265 |
-
{"listing_id": "system"}
|
| 266 |
-
]}
|
| 267 |
-
]
|
| 268 |
-
})
|
| 269 |
-
if existing_conversation:
|
| 270 |
-
logger.info(f"Found by broad AIDA query after race: {existing_conversation}")
|
| 271 |
-
|
| 272 |
-
if existing_conversation:
|
| 273 |
-
conv_id = str(existing_conversation["_id"])
|
| 274 |
-
logger.info(f"Found existing AIDA conversation after race: {conv_id}")
|
| 275 |
-
|
| 276 |
-
# Get actual participants from the found conversation
|
| 277 |
-
actual_participants = existing_conversation.get("participants", participants)
|
| 278 |
-
|
| 279 |
-
enriched_participants, other_participant = await self._enrich_participants(
|
| 280 |
-
db, actual_participants, current_user_id
|
| 281 |
-
)
|
| 282 |
-
|
| 283 |
-
conv_response = Conversation.format_response(existing_conversation)
|
| 284 |
-
conv_response["participants"] = enriched_participants
|
| 285 |
-
conv_response["other_participant"] = other_participant
|
| 286 |
-
|
| 287 |
-
return {
|
| 288 |
-
"success": True,
|
| 289 |
-
"is_new": False,
|
| 290 |
-
"conversation": conv_response,
|
| 291 |
-
}
|
| 292 |
-
else:
|
| 293 |
-
# Log all indexes on the collection to help debug
|
| 294 |
-
try:
|
| 295 |
-
indexes = await db.conversations.index_information()
|
| 296 |
-
logger.error(f"Collection indexes: {indexes}")
|
| 297 |
-
except:
|
| 298 |
-
pass
|
| 299 |
-
logger.error(f"Could not find conversation after duplicate key error. user_id={current_user_id}, participants_key={participants_key}")
|
| 300 |
-
raise HTTPException(
|
| 301 |
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 302 |
-
detail=f"Failed to create AIDA conversation. Duplicate key but no match found."
|
| 303 |
-
)
|
| 304 |
-
else:
|
| 305 |
-
logger.error(f"Unexpected error creating conversation: {e}")
|
| 306 |
-
raise
|
| 307 |
-
|
| 308 |
-
# 3. Enrich with participants data
|
| 309 |
-
enriched_participants, other_participant = await self._enrich_participants(
|
| 310 |
-
db, participants, current_user_id
|
| 311 |
-
)
|
| 312 |
-
|
| 313 |
-
conv_response = Conversation.format_response(conversation_doc)
|
| 314 |
-
conv_response["participants"] = enriched_participants
|
| 315 |
-
conv_response["other_participant"] = other_participant
|
| 316 |
-
|
| 317 |
-
return {
|
| 318 |
-
"success": True,
|
| 319 |
-
"is_new": True,
|
| 320 |
-
"conversation": conv_response,
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
async def _enrich_participants(self, db, participant_ids: list[str], current_user_id: str = None) -> tuple[list[dict], dict]:
|
| 324 |
-
"""
|
| 325 |
-
Enrich participant IDs with full user data and online status.
|
| 326 |
-
Returns (all_participants, other_participant).
|
| 327 |
-
"""
|
| 328 |
-
from app.services.presence_service import presence_service
|
| 329 |
-
|
| 330 |
-
AIDA_BOT_ID = "AIDA_BOT"
|
| 331 |
-
AIDA_PROFILE = {
|
| 332 |
-
"id": AIDA_BOT_ID,
|
| 333 |
-
"name": "AIDA",
|
| 334 |
-
"profile_picture": "https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 335 |
-
"is_online": True, # AIDA is always online
|
| 336 |
-
"last_seen": None,
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
participants = []
|
| 340 |
-
other_participant = None
|
| 341 |
-
|
| 342 |
-
# AIDA IDs to handle (both new and legacy)
|
| 343 |
-
AIDA_IDS = {AIDA_BOT_ID, "ai_assistant"}
|
| 344 |
-
|
| 345 |
-
# Batch fetch all users (skip AIDA IDs)
|
| 346 |
-
users_map = {}
|
| 347 |
-
real_user_ids = [uid for uid in participant_ids if uid not in AIDA_IDS]
|
| 348 |
-
|
| 349 |
-
for uid in real_user_ids:
|
| 350 |
-
if ObjectId.is_valid(uid):
|
| 351 |
-
user = await db.users.find_one({"_id": ObjectId(uid)})
|
| 352 |
-
if user:
|
| 353 |
-
users_map[uid] = user
|
| 354 |
-
|
| 355 |
-
# Get online statuses (only for real users)
|
| 356 |
-
online_statuses = await presence_service.get_bulk_status(real_user_ids) if real_user_ids else {}
|
| 357 |
-
|
| 358 |
-
# Build enriched participants
|
| 359 |
-
for uid in participant_ids:
|
| 360 |
-
# Handle AIDA (both AIDA_BOT and ai_assistant) specially
|
| 361 |
-
if uid in AIDA_IDS:
|
| 362 |
-
participant = AIDA_PROFILE.copy()
|
| 363 |
-
else:
|
| 364 |
-
user = users_map.get(uid)
|
| 365 |
-
status_data = online_statuses.get(uid, {"is_online": False, "last_seen": None})
|
| 366 |
-
|
| 367 |
-
participant = {
|
| 368 |
-
"id": uid,
|
| 369 |
-
"name": f"{user.get('firstName', '')} {user.get('lastName', '')}".strip() if user else "Unknown",
|
| 370 |
-
"profile_picture": user.get("profilePicture") if user else None,
|
| 371 |
-
"is_online": status_data.get("is_online", False),
|
| 372 |
-
"last_seen": status_data.get("last_seen"),
|
| 373 |
-
}
|
| 374 |
-
|
| 375 |
-
participants.append(participant)
|
| 376 |
-
|
| 377 |
-
# Track the OTHER participant (not current user)
|
| 378 |
-
if current_user_id and uid != current_user_id:
|
| 379 |
-
other_participant = participant
|
| 380 |
-
|
| 381 |
-
return participants, other_participant
|
| 382 |
-
|
| 383 |
-
async def get_user_conversations(
|
| 384 |
-
self,
|
| 385 |
-
user_id: str,
|
| 386 |
-
) -> list[dict]:
|
| 387 |
-
"""
|
| 388 |
-
Get all conversations for a user with ENRICHED participant data.
|
| 389 |
-
|
| 390 |
-
Response includes for each conversation:
|
| 391 |
-
- participants: List with name, avatar, online status
|
| 392 |
-
- other_participant: Quick access to the other person
|
| 393 |
-
- unread_count: Just for current user (not dict)
|
| 394 |
-
"""
|
| 395 |
-
db = await get_db()
|
| 396 |
-
|
| 397 |
-
cursor = db.conversations.find({
|
| 398 |
-
"participants": user_id
|
| 399 |
-
}).sort("updated_at", -1)
|
| 400 |
-
|
| 401 |
-
conversations = []
|
| 402 |
-
async for doc in cursor:
|
| 403 |
-
conv_id = str(doc["_id"])
|
| 404 |
-
participant_ids = doc.get("participants", [])
|
| 405 |
-
|
| 406 |
-
# Enrich participants with user data + online status
|
| 407 |
-
enriched_participants, other_participant = await self._enrich_participants(
|
| 408 |
-
db, participant_ids, current_user_id=user_id
|
| 409 |
-
)
|
| 410 |
-
|
| 411 |
-
# Get unread count for current user only
|
| 412 |
-
unread_counts = doc.get("unread_count", {})
|
| 413 |
-
user_unread = unread_counts.get(user_id, 0) if isinstance(unread_counts, dict) else 0
|
| 414 |
-
|
| 415 |
-
# Build enriched conversation response
|
| 416 |
-
conversations.append({
|
| 417 |
-
"id": conv_id,
|
| 418 |
-
"listing_id": doc.get("listing_id", ""),
|
| 419 |
-
"listing_title": doc.get("listing_title", ""),
|
| 420 |
-
"listing_image": doc.get("listing_image"),
|
| 421 |
-
"participants": enriched_participants,
|
| 422 |
-
"other_participant": other_participant,
|
| 423 |
-
"last_message": doc.get("last_message", {}),
|
| 424 |
-
"unread_count": user_unread,
|
| 425 |
-
"status": doc.get("status", "active"),
|
| 426 |
-
"created_at": doc.get("created_at"),
|
| 427 |
-
"updated_at": doc.get("updated_at"),
|
| 428 |
-
})
|
| 429 |
-
|
| 430 |
-
logger.info(f"Found {len(conversations)} enriched conversations for user {user_id}")
|
| 431 |
-
|
| 432 |
-
return conversations
|
| 433 |
-
|
| 434 |
-
async def get_conversation_by_id(
|
| 435 |
-
self,
|
| 436 |
-
conversation_id: str,
|
| 437 |
-
current_user_id: str,
|
| 438 |
-
include_messages: bool = False,
|
| 439 |
-
mark_as_read: bool = False,
|
| 440 |
-
message_limit: int = 50,
|
| 441 |
-
) -> dict:
|
| 442 |
-
"""
|
| 443 |
-
Get a single conversation with optional messages.
|
| 444 |
-
|
| 445 |
-
This is the COMBINED endpoint that can:
|
| 446 |
-
- Return conversation with enriched participants
|
| 447 |
-
- Include messages (eliminating separate /messages call)
|
| 448 |
-
- Auto-mark as read (eliminating separate /read call)
|
| 449 |
-
"""
|
| 450 |
-
db = await get_db()
|
| 451 |
-
|
| 452 |
-
# Validate conversation ID
|
| 453 |
-
if not ObjectId.is_valid(conversation_id):
|
| 454 |
-
raise HTTPException(
|
| 455 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 456 |
-
detail="Invalid conversation ID format"
|
| 457 |
-
)
|
| 458 |
-
|
| 459 |
-
# Fetch conversation
|
| 460 |
-
doc = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 461 |
-
if not doc:
|
| 462 |
-
raise HTTPException(
|
| 463 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 464 |
-
detail="Conversation not found"
|
| 465 |
-
)
|
| 466 |
-
|
| 467 |
-
# Verify user is participant
|
| 468 |
-
if current_user_id not in doc.get("participants", []):
|
| 469 |
-
raise HTTPException(
|
| 470 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 471 |
-
detail="You are not a participant in this conversation"
|
| 472 |
-
)
|
| 473 |
-
|
| 474 |
-
# Enrich participants
|
| 475 |
-
participant_ids = doc.get("participants", [])
|
| 476 |
-
enriched_participants, other_participant = await self._enrich_participants(
|
| 477 |
-
db, participant_ids, current_user_id=current_user_id
|
| 478 |
-
)
|
| 479 |
-
|
| 480 |
-
# Get unread count for current user
|
| 481 |
-
unread_counts = doc.get("unread_count", {})
|
| 482 |
-
user_unread = unread_counts.get(current_user_id, 0) if isinstance(unread_counts, dict) else 0
|
| 483 |
-
|
| 484 |
-
# Build conversation response
|
| 485 |
-
conversation_data = {
|
| 486 |
-
"id": str(doc["_id"]),
|
| 487 |
-
"listing_id": doc.get("listing_id", ""),
|
| 488 |
-
"listing_title": doc.get("listing_title", ""),
|
| 489 |
-
"listing_image": doc.get("listing_image"),
|
| 490 |
-
"participants": enriched_participants,
|
| 491 |
-
"other_participant": other_participant,
|
| 492 |
-
"last_message": doc.get("last_message", {}),
|
| 493 |
-
"unread_count": user_unread,
|
| 494 |
-
"status": doc.get("status", "active"),
|
| 495 |
-
"created_at": doc.get("created_at"),
|
| 496 |
-
"updated_at": doc.get("updated_at"),
|
| 497 |
-
}
|
| 498 |
-
|
| 499 |
-
result = {
|
| 500 |
-
"conversation": conversation_data,
|
| 501 |
-
"messages": [],
|
| 502 |
-
"has_more": False,
|
| 503 |
-
"total_messages": 0,
|
| 504 |
-
}
|
| 505 |
-
|
| 506 |
-
# Include messages if requested
|
| 507 |
-
if include_messages:
|
| 508 |
-
messages = await self.get_conversation_messages(
|
| 509 |
-
conversation_id=conversation_id,
|
| 510 |
-
current_user_id=current_user_id,
|
| 511 |
-
limit=message_limit,
|
| 512 |
-
)
|
| 513 |
-
|
| 514 |
-
# Count total messages
|
| 515 |
-
total_count = await db.messages.count_documents({
|
| 516 |
-
"conversation_id": conversation_id,
|
| 517 |
-
"deleted_for": {"$ne": current_user_id},
|
| 518 |
-
})
|
| 519 |
-
|
| 520 |
-
result["messages"] = messages
|
| 521 |
-
result["has_more"] = total_count > len(messages)
|
| 522 |
-
result["total_messages"] = total_count
|
| 523 |
-
|
| 524 |
-
# Mark as read if requested
|
| 525 |
-
if mark_as_read:
|
| 526 |
-
await self.mark_as_read(conversation_id, current_user_id)
|
| 527 |
-
result["conversation"]["unread_count"] = 0
|
| 528 |
-
|
| 529 |
-
return result
|
| 530 |
-
|
| 531 |
-
async def get_conversation_messages(
|
| 532 |
-
self,
|
| 533 |
-
conversation_id: str,
|
| 534 |
-
current_user_id: str,
|
| 535 |
-
limit: int = 50,
|
| 536 |
-
before_id: Optional[str] = None,
|
| 537 |
-
) -> list[dict]:
|
| 538 |
-
"""
|
| 539 |
-
Get messages for a conversation with pagination.
|
| 540 |
-
"""
|
| 541 |
-
db = await get_db()
|
| 542 |
-
|
| 543 |
-
# Validate conversation ID
|
| 544 |
-
if not ObjectId.is_valid(conversation_id):
|
| 545 |
-
raise HTTPException(
|
| 546 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 547 |
-
detail="Invalid conversation ID format"
|
| 548 |
-
)
|
| 549 |
-
|
| 550 |
-
# Verify user is participant
|
| 551 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 552 |
-
if not conversation:
|
| 553 |
-
raise HTTPException(
|
| 554 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 555 |
-
detail="Conversation not found"
|
| 556 |
-
)
|
| 557 |
-
|
| 558 |
-
if current_user_id not in conversation.get("participants", []):
|
| 559 |
-
raise HTTPException(
|
| 560 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 561 |
-
detail="You are not a participant in this conversation"
|
| 562 |
-
)
|
| 563 |
-
|
| 564 |
-
# Check if user has cleared the chat - get their cleared_at timestamp
|
| 565 |
-
user_cleared_at = conversation.get("cleared_at", {}).get(current_user_id)
|
| 566 |
-
|
| 567 |
-
# Build query - exclude messages deleted specifically for this user
|
| 568 |
-
query = {
|
| 569 |
-
"conversation_id": conversation_id,
|
| 570 |
-
"deleted_for": {"$ne": current_user_id}, # Exclude messages deleted for this user
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
# Filter out messages sent before user cleared the chat
|
| 574 |
-
# This ensures persistence across logout/login and new devices
|
| 575 |
-
if user_cleared_at:
|
| 576 |
-
query["created_at"] = {"$gt": user_cleared_at}
|
| 577 |
-
|
| 578 |
-
if before_id and ObjectId.is_valid(before_id):
|
| 579 |
-
if "created_at" in query:
|
| 580 |
-
# Combine with existing created_at filter
|
| 581 |
-
query["$and"] = [
|
| 582 |
-
{"created_at": query.pop("created_at")},
|
| 583 |
-
{"_id": {"$lt": ObjectId(before_id)}}
|
| 584 |
-
]
|
| 585 |
-
else:
|
| 586 |
-
query["_id"] = {"$lt": ObjectId(before_id)}
|
| 587 |
-
|
| 588 |
-
# Get messages - sort ascending (oldest first, newest last)
|
| 589 |
-
# This gives chronological order: oldest at top, newest at bottom
|
| 590 |
-
cursor = db.messages.find(query).sort("created_at", 1).limit(limit)
|
| 591 |
-
|
| 592 |
-
messages = []
|
| 593 |
-
async for doc in cursor:
|
| 594 |
-
# Pass user_id for proper filtering in format_response
|
| 595 |
-
formatted = Message.format_response(doc, for_user_id=current_user_id)
|
| 596 |
-
if formatted: # Only add if not None (handles edge cases)
|
| 597 |
-
messages.append(formatted)
|
| 598 |
-
|
| 599 |
-
logger.info(f"Found {len(messages)} messages for conversation {conversation_id}")
|
| 600 |
-
|
| 601 |
-
return messages
|
| 602 |
-
|
| 603 |
-
async def send_message(
|
| 604 |
-
self,
|
| 605 |
-
conversation_id: str,
|
| 606 |
-
current_user_id: str,
|
| 607 |
-
current_user_name: str,
|
| 608 |
-
current_user_avatar: Optional[str],
|
| 609 |
-
message_type: str,
|
| 610 |
-
content: Optional[str] = None,
|
| 611 |
-
media: Optional[dict] = None,
|
| 612 |
-
property_card: Optional[dict] = None,
|
| 613 |
-
replied_to_message_id: Optional[str] = None,
|
| 614 |
-
replied_to_content: Optional[str] = None,
|
| 615 |
-
replied_to_sender: Optional[str] = None,
|
| 616 |
-
) -> dict:
|
| 617 |
-
"""
|
| 618 |
-
Send a message in a conversation.
|
| 619 |
-
"""
|
| 620 |
-
db = await get_db()
|
| 621 |
-
|
| 622 |
-
# Validate conversation
|
| 623 |
-
if not ObjectId.is_valid(conversation_id):
|
| 624 |
-
raise HTTPException(
|
| 625 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 626 |
-
detail="Invalid conversation ID format"
|
| 627 |
-
)
|
| 628 |
-
|
| 629 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 630 |
-
if not conversation:
|
| 631 |
-
raise HTTPException(
|
| 632 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 633 |
-
detail="Conversation not found"
|
| 634 |
-
)
|
| 635 |
-
|
| 636 |
-
if current_user_id not in conversation.get("participants", []):
|
| 637 |
-
raise HTTPException(
|
| 638 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 639 |
-
detail="You are not a participant in this conversation"
|
| 640 |
-
)
|
| 641 |
-
|
| 642 |
-
# Auto-detect message type based on content
|
| 643 |
-
if property_card:
|
| 644 |
-
message_type = "property_inquiry" # Override type when property card is present
|
| 645 |
-
|
| 646 |
-
# Create message
|
| 647 |
-
message_doc = Message.create_document(
|
| 648 |
-
conversation_id=conversation_id,
|
| 649 |
-
sender_id=current_user_id,
|
| 650 |
-
sender_name=current_user_name,
|
| 651 |
-
sender_avatar=current_user_avatar,
|
| 652 |
-
message_type=message_type,
|
| 653 |
-
content=content,
|
| 654 |
-
media=media,
|
| 655 |
-
property_card=property_card,
|
| 656 |
-
replied_to_message_id=replied_to_message_id,
|
| 657 |
-
replied_to_content=replied_to_content,
|
| 658 |
-
replied_to_sender=replied_to_sender,
|
| 659 |
-
)
|
| 660 |
-
|
| 661 |
-
result = await db.messages.insert_one(message_doc)
|
| 662 |
-
message_id = str(result.inserted_id)
|
| 663 |
-
|
| 664 |
-
# Update conversation's last_message and unread count
|
| 665 |
-
other_user_id = next(
|
| 666 |
-
(p for p in conversation["participants"] if p != current_user_id),
|
| 667 |
-
None
|
| 668 |
-
)
|
| 669 |
-
|
| 670 |
-
update_data = {
|
| 671 |
-
"last_message": {
|
| 672 |
-
"text": content or f"[{message_type}]",
|
| 673 |
-
"sender_id": current_user_id,
|
| 674 |
-
"timestamp": message_doc["created_at"],
|
| 675 |
-
},
|
| 676 |
-
"updated_at": datetime.utcnow(),
|
| 677 |
-
}
|
| 678 |
-
|
| 679 |
-
# Increment unread count for other user
|
| 680 |
-
if other_user_id:
|
| 681 |
-
update_data[f"unread_count.{other_user_id}"] = conversation.get("unread_count", {}).get(other_user_id, 0) + 1
|
| 682 |
-
|
| 683 |
-
await db.conversations.update_one(
|
| 684 |
-
{"_id": ObjectId(conversation_id)},
|
| 685 |
-
{"$set": update_data}
|
| 686 |
-
)
|
| 687 |
-
|
| 688 |
-
logger.info(f"Message {message_id} sent in conversation {conversation_id}")
|
| 689 |
-
|
| 690 |
-
# Get message with ID
|
| 691 |
-
message_doc["_id"] = result.inserted_id
|
| 692 |
-
|
| 693 |
-
# Broadcast to all participants via WebSocket (for in-app notifications)
|
| 694 |
-
try:
|
| 695 |
-
formatted_message = Message.format_response(message_doc)
|
| 696 |
-
broadcast_message = {
|
| 697 |
-
"action": "new_message",
|
| 698 |
-
"conversation_id": conversation_id,
|
| 699 |
-
"message": formatted_message,
|
| 700 |
-
}
|
| 701 |
-
await chat_manager.broadcast_to_conversation(
|
| 702 |
-
conversation_id,
|
| 703 |
-
conversation["participants"],
|
| 704 |
-
broadcast_message
|
| 705 |
-
)
|
| 706 |
-
logger.info(f"WebSocket broadcast sent for message {message_id}")
|
| 707 |
-
except Exception as e:
|
| 708 |
-
# Don't fail the request if WebSocket broadcast fails
|
| 709 |
-
logger.warning(f"Failed to broadcast message via WebSocket: {e}")
|
| 710 |
-
|
| 711 |
-
# NEW: Detect AIDA chat and route to AI brain
|
| 712 |
-
AIDA_IDS = {"AIDA_BOT", "ai_assistant"}
|
| 713 |
-
is_aida_chat = any(p in AIDA_IDS for p in conversation.get("participants", []))
|
| 714 |
-
|
| 715 |
-
# Process if AIDA chat, user is not AIDA, and message is TEXT or VOICE
|
| 716 |
-
# (check content for text, or media for voice)
|
| 717 |
-
should_process = (
|
| 718 |
-
is_aida_chat and
|
| 719 |
-
current_user_id not in AIDA_IDS and
|
| 720 |
-
(
|
| 721 |
-
(message_type == "text" and content) or
|
| 722 |
-
(message_type == "voice" and media and media.get("url"))
|
| 723 |
-
)
|
| 724 |
-
)
|
| 725 |
-
|
| 726 |
-
if should_process:
|
| 727 |
-
try:
|
| 728 |
-
logger.info(f"AIDA DM detected (type={message_type}), routing to AI brain...")
|
| 729 |
-
|
| 730 |
-
# Extract audio URL for voice messages
|
| 731 |
-
audio_url = media.get("url") if message_type == "voice" and media else None
|
| 732 |
-
|
| 733 |
-
# Build reply context if replying to a message
|
| 734 |
-
reply_context = None
|
| 735 |
-
if replied_to_message_id:
|
| 736 |
-
replied_msg = await db.messages.find_one({"_id": ObjectId(replied_to_message_id)})
|
| 737 |
-
if replied_msg:
|
| 738 |
-
reply_context = {
|
| 739 |
-
"message_id": replied_to_message_id,
|
| 740 |
-
"message_content": replied_msg.get("content"),
|
| 741 |
-
"property_card": replied_msg.get("property_card"),
|
| 742 |
-
"metadata": replied_msg.get("metadata"),
|
| 743 |
-
}
|
| 744 |
-
|
| 745 |
-
# Call AIDA brain (async, don't block response)
|
| 746 |
-
import asyncio
|
| 747 |
-
asyncio.create_task(self._process_aida_response(
|
| 748 |
-
db=db,
|
| 749 |
-
conversation_id=conversation_id,
|
| 750 |
-
conversation=conversation,
|
| 751 |
-
user_id=current_user_id,
|
| 752 |
-
user_message=content,
|
| 753 |
-
user_message_id=message_id,
|
| 754 |
-
reply_context=reply_context,
|
| 755 |
-
audio_url=audio_url,
|
| 756 |
-
))
|
| 757 |
-
|
| 758 |
-
except Exception as e:
|
| 759 |
-
logger.error(f"Failed to route to AIDA brain: {e}")
|
| 760 |
-
|
| 761 |
-
return {
|
| 762 |
-
"success": True,
|
| 763 |
-
"message": Message.format_response(message_doc),
|
| 764 |
-
}
|
| 765 |
-
|
| 766 |
-
async def _process_aida_response(
|
| 767 |
-
self,
|
| 768 |
-
db,
|
| 769 |
-
conversation_id: str,
|
| 770 |
-
conversation: dict,
|
| 771 |
-
user_id: str,
|
| 772 |
-
user_message: str,
|
| 773 |
-
user_message_id: str,
|
| 774 |
-
reply_context: Optional[dict] = None,
|
| 775 |
-
audio_url: Optional[str] = None,
|
| 776 |
-
):
|
| 777 |
-
"""
|
| 778 |
-
Process user message through AIDA AI brain and send response.
|
| 779 |
-
Runs asynchronously so user sees their message immediately.
|
| 780 |
-
"""
|
| 781 |
-
try:
|
| 782 |
-
logger.info(f"Processing AIDA response for user {user_id}")
|
| 783 |
-
|
| 784 |
-
# 1. Handle Voice Transcription if needed
|
| 785 |
-
if audio_url:
|
| 786 |
-
try:
|
| 787 |
-
from app.services.voice_service import voice_service
|
| 788 |
-
logger.info(f"Transcribing voice message: {audio_url}")
|
| 789 |
-
transcript, lang = await voice_service.transcribe_audio(audio_url)
|
| 790 |
-
|
| 791 |
-
if transcript:
|
| 792 |
-
user_message = transcript
|
| 793 |
-
logger.info(f"Voice transcribed: '{user_message}' ({lang})")
|
| 794 |
-
|
| 795 |
-
# Optionally update the original message with transcript?
|
| 796 |
-
# For now, we just let AIDA know the content
|
| 797 |
-
else:
|
| 798 |
-
logger.warning("Empty transcription result")
|
| 799 |
-
user_message = "(Inaudible voice message)"
|
| 800 |
-
|
| 801 |
-
except Exception as e:
|
| 802 |
-
logger.error(f"Transcription failed: {e}")
|
| 803 |
-
# Don't crash, just let AIDA handle failure
|
| 804 |
-
user_message = "(Voice message could not be transcribed)"
|
| 805 |
-
|
| 806 |
-
# Import specialized DM brain
|
| 807 |
-
from app.ai.agent.dm_brain import DmBrain
|
| 808 |
-
|
| 809 |
-
# Build context message with reply info
|
| 810 |
-
context_message = user_message
|
| 811 |
-
if reply_context:
|
| 812 |
-
property_card = reply_context.get("property_card")
|
| 813 |
-
replied_content = reply_context.get("message_content", "")
|
| 814 |
-
metadata = reply_context.get("metadata", {})
|
| 815 |
-
alert_title = metadata.get("alert_title") if metadata else None
|
| 816 |
-
|
| 817 |
-
if property_card:
|
| 818 |
-
listing_id = property_card.get("listing_id")
|
| 819 |
-
listing_title = property_card.get("title")
|
| 820 |
-
listing_location = property_card.get("location")
|
| 821 |
-
|
| 822 |
-
context_message = f"""{user_message}
|
| 823 |
-
|
| 824 |
-
[System Context: User is replying to property listing:
|
| 825 |
-
- ID: {listing_id}
|
| 826 |
-
- Title: {listing_title}
|
| 827 |
-
- Location: {listing_location}
|
| 828 |
-
|
| 829 |
-
When user says "this", "it", "this property", they mean this listing.
|
| 830 |
-
If user says they found what they wanted, use delete_alert tool with location matching this listing.]"""
|
| 831 |
-
|
| 832 |
-
elif alert_title:
|
| 833 |
-
context_message = f"""{user_message}
|
| 834 |
-
|
| 835 |
-
[System Context: User is replying to alert notification for: "{alert_title}"
|
| 836 |
-
If user says they found what they wanted or wants to stop notifications, use delete_alert tool.]"""
|
| 837 |
-
|
| 838 |
-
elif replied_content:
|
| 839 |
-
context_message = f"""{user_message}
|
| 840 |
-
|
| 841 |
-
[System Context: User is replying to AIDA's previous message: "{replied_content}"]"""
|
| 842 |
-
|
| 843 |
-
# Call specialized DM brain (architecture separation)
|
| 844 |
-
brain = DmBrain()
|
| 845 |
-
result = await brain.process(
|
| 846 |
-
message=context_message,
|
| 847 |
-
user_id=user_id,
|
| 848 |
-
source="dm",
|
| 849 |
-
)
|
| 850 |
-
|
| 851 |
-
# Extract response text and metadata
|
| 852 |
-
response_text = result.get("text", "I'm sorry, I couldn't process that. Please try again.")
|
| 853 |
-
response_metadata = result.get("metadata", {})
|
| 854 |
-
|
| 855 |
-
# Check for property cards or alert results
|
| 856 |
-
property_card = None
|
| 857 |
-
if response_metadata.get("property_card"):
|
| 858 |
-
property_card = response_metadata.get("property_card")
|
| 859 |
-
|
| 860 |
-
# ============================================================
|
| 861 |
-
# VOICE RESPONSE: If user sent a voice note, AIDA responds with voice too
|
| 862 |
-
# ============================================================
|
| 863 |
-
aida_audio_url = None
|
| 864 |
-
aida_audio_duration = None
|
| 865 |
-
message_type = "text" # Default to text
|
| 866 |
-
|
| 867 |
-
if audio_url:
|
| 868 |
-
# User sent voice, so AIDA should respond with voice
|
| 869 |
-
try:
|
| 870 |
-
from app.services.voice_service import voice_service
|
| 871 |
-
|
| 872 |
-
# Determine language from transcription or default to English
|
| 873 |
-
response_language = "en"
|
| 874 |
-
|
| 875 |
-
# Generate AIDA's voice response
|
| 876 |
-
logger.info(f"Generating AIDA voice response for DM...")
|
| 877 |
-
voice_result = await voice_service.generate_aida_voice_response(
|
| 878 |
-
text=response_text,
|
| 879 |
-
language=response_language
|
| 880 |
-
)
|
| 881 |
-
|
| 882 |
-
if voice_result:
|
| 883 |
-
aida_audio_url = voice_result.get("audio_url")
|
| 884 |
-
aida_audio_duration = voice_result.get("duration")
|
| 885 |
-
message_type = "voice" # Change message type to voice
|
| 886 |
-
logger.info(f"✅ AIDA voice response generated: {aida_audio_url}")
|
| 887 |
-
|
| 888 |
-
except Exception as e:
|
| 889 |
-
logger.error(f"Failed to generate AIDA voice response: {e}")
|
| 890 |
-
# Fall back to text response
|
| 891 |
-
message_type = "text"
|
| 892 |
-
|
| 893 |
-
# Send AIDA's response as a message in the conversation
|
| 894 |
-
aida_message_doc = Message.create_document(
|
| 895 |
-
conversation_id=conversation_id,
|
| 896 |
-
sender_id="AIDA_BOT",
|
| 897 |
-
sender_name="AIDA",
|
| 898 |
-
sender_avatar="https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 899 |
-
message_type=message_type,
|
| 900 |
-
content=response_text,
|
| 901 |
-
property_card=property_card,
|
| 902 |
-
replied_to_message_id=user_message_id if reply_context else None,
|
| 903 |
-
replied_to_content=user_message if reply_context else None,
|
| 904 |
-
replied_to_sender="User" if reply_context else None,
|
| 905 |
-
)
|
| 906 |
-
|
| 907 |
-
# Add voice-specific fields if this is a voice response
|
| 908 |
-
if aida_audio_url:
|
| 909 |
-
aida_message_doc["audio_url"] = aida_audio_url
|
| 910 |
-
aida_message_doc["audio_duration"] = aida_audio_duration
|
| 911 |
-
|
| 912 |
-
# Add metadata for rich content (alert results, etc.)
|
| 913 |
-
if response_metadata:
|
| 914 |
-
aida_message_doc["metadata"] = response_metadata
|
| 915 |
-
|
| 916 |
-
# Insert AIDA's message
|
| 917 |
-
aida_result = await db.messages.insert_one(aida_message_doc)
|
| 918 |
-
aida_message_id = str(aida_result.inserted_id)
|
| 919 |
-
aida_message_doc["_id"] = aida_result.inserted_id
|
| 920 |
-
|
| 921 |
-
logger.info(f"AIDA response {aida_message_id} sent in conversation {conversation_id}")
|
| 922 |
-
|
| 923 |
-
# Update conversation's last_message (AIDA's response)
|
| 924 |
-
update_data = {
|
| 925 |
-
"last_message": {
|
| 926 |
-
"text": response_text[:100] if response_text else "[AI Response]",
|
| 927 |
-
"sender_id": "AIDA_BOT",
|
| 928 |
-
"timestamp": aida_message_doc["created_at"],
|
| 929 |
-
},
|
| 930 |
-
"updated_at": datetime.utcnow(),
|
| 931 |
-
}
|
| 932 |
-
|
| 933 |
-
# Increment unread count for user (AIDA just sent a message)
|
| 934 |
-
update_data[f"unread_count.{user_id}"] = conversation.get("unread_count", {}).get(user_id, 0) + 1
|
| 935 |
-
|
| 936 |
-
await db.conversations.update_one(
|
| 937 |
-
{"_id": ObjectId(conversation_id)},
|
| 938 |
-
{"$set": update_data}
|
| 939 |
-
)
|
| 940 |
-
|
| 941 |
-
# Broadcast AIDA's response via WebSocket
|
| 942 |
-
try:
|
| 943 |
-
formatted_message = Message.format_response(aida_message_doc)
|
| 944 |
-
broadcast_message = {
|
| 945 |
-
"action": "new_message",
|
| 946 |
-
"conversation_id": conversation_id,
|
| 947 |
-
"message": formatted_message,
|
| 948 |
-
}
|
| 949 |
-
await chat_manager.broadcast_to_conversation(
|
| 950 |
-
conversation_id,
|
| 951 |
-
conversation["participants"],
|
| 952 |
-
broadcast_message
|
| 953 |
-
)
|
| 954 |
-
logger.info(f"AIDA response broadcast via WebSocket")
|
| 955 |
-
except Exception as e:
|
| 956 |
-
logger.warning(f"Failed to broadcast AIDA response: {e}")
|
| 957 |
-
|
| 958 |
-
except Exception as e:
|
| 959 |
-
logger.error(f"Error processing AIDA response: {e}")
|
| 960 |
-
# Optionally send error message to user
|
| 961 |
-
try:
|
| 962 |
-
error_message_doc = Message.create_document(
|
| 963 |
-
conversation_id=conversation_id,
|
| 964 |
-
sender_id="AIDA_BOT",
|
| 965 |
-
sender_name="AIDA",
|
| 966 |
-
sender_avatar="https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/3922956f-b69d-4cb3-97b9-3a185abec900/public",
|
| 967 |
-
message_type="text",
|
| 968 |
-
content="I'm having trouble processing that right now. Please try again in a moment. 😅",
|
| 969 |
-
)
|
| 970 |
-
await db.messages.insert_one(error_message_doc)
|
| 971 |
-
except:
|
| 972 |
-
pass
|
| 973 |
-
|
| 974 |
-
async def mark_as_read(
|
| 975 |
-
self,
|
| 976 |
-
conversation_id: str,
|
| 977 |
-
current_user_id: str,
|
| 978 |
-
) -> dict:
|
| 979 |
-
"""
|
| 980 |
-
Mark all messages in a conversation as read for the current user.
|
| 981 |
-
"""
|
| 982 |
-
db = await get_db()
|
| 983 |
-
|
| 984 |
-
# Validate conversation
|
| 985 |
-
if not ObjectId.is_valid(conversation_id):
|
| 986 |
-
raise HTTPException(
|
| 987 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 988 |
-
detail="Invalid conversation ID format"
|
| 989 |
-
)
|
| 990 |
-
|
| 991 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 992 |
-
if not conversation:
|
| 993 |
-
raise HTTPException(
|
| 994 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 995 |
-
detail="Conversation not found"
|
| 996 |
-
)
|
| 997 |
-
|
| 998 |
-
if current_user_id not in conversation.get("participants", []):
|
| 999 |
-
raise HTTPException(
|
| 1000 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1001 |
-
detail="You are not a participant in this conversation"
|
| 1002 |
-
)
|
| 1003 |
-
|
| 1004 |
-
# Mark messages as read and add user to read_by array
|
| 1005 |
-
now = datetime.utcnow()
|
| 1006 |
-
result = await db.messages.update_many(
|
| 1007 |
-
{
|
| 1008 |
-
"conversation_id": conversation_id,
|
| 1009 |
-
"sender_id": {"$ne": current_user_id}, # Not sent by this user
|
| 1010 |
-
"read_by": {"$ne": current_user_id}, # Not already read by this user
|
| 1011 |
-
},
|
| 1012 |
-
{
|
| 1013 |
-
"$set": {
|
| 1014 |
-
"is_read": True, # Keep for backwards compatibility
|
| 1015 |
-
"read_at": now,
|
| 1016 |
-
},
|
| 1017 |
-
"$addToSet": {
|
| 1018 |
-
"read_by": current_user_id # Add user to read_by array (no duplicates)
|
| 1019 |
-
}
|
| 1020 |
-
}
|
| 1021 |
-
)
|
| 1022 |
-
|
| 1023 |
-
logger.info(f"Marked {result.modified_count} messages as read, added {current_user_id} to read_by")
|
| 1024 |
-
|
| 1025 |
-
# Reset unread count for current user
|
| 1026 |
-
await db.conversations.update_one(
|
| 1027 |
-
{"_id": ObjectId(conversation_id)},
|
| 1028 |
-
{"$set": {f"unread_count.{current_user_id}": 0}}
|
| 1029 |
-
)
|
| 1030 |
-
|
| 1031 |
-
# Broadcast message_read event to other participants via WebSocket
|
| 1032 |
-
try:
|
| 1033 |
-
from app.routes.websocket_chat import chat_manager
|
| 1034 |
-
|
| 1035 |
-
read_event = {
|
| 1036 |
-
"action": "message_read",
|
| 1037 |
-
"conversation_id": conversation_id,
|
| 1038 |
-
"read_by": current_user_id,
|
| 1039 |
-
"read_at": now.isoformat(),
|
| 1040 |
-
}
|
| 1041 |
-
|
| 1042 |
-
# Send to other participants
|
| 1043 |
-
for participant_id in conversation.get("participants", []):
|
| 1044 |
-
if participant_id != current_user_id:
|
| 1045 |
-
await chat_manager.send_to_user(participant_id, read_event)
|
| 1046 |
-
|
| 1047 |
-
logger.info(f"Broadcasted message_read event for conversation {conversation_id}")
|
| 1048 |
-
except Exception as e:
|
| 1049 |
-
# Don't fail the request if WebSocket broadcast fails
|
| 1050 |
-
logger.warning(f"Failed to broadcast message_read event: {e}")
|
| 1051 |
-
|
| 1052 |
-
logger.info(f"Marked messages as read in conversation {conversation_id} for user {current_user_id}")
|
| 1053 |
-
|
| 1054 |
-
return {
|
| 1055 |
-
"success": True,
|
| 1056 |
-
"message": "Messages marked as read",
|
| 1057 |
-
}
|
| 1058 |
-
|
| 1059 |
-
# ============================================================
|
| 1060 |
-
# NEW METHODS: Edit, Delete, Reactions, Clear Chat
|
| 1061 |
-
# ============================================================
|
| 1062 |
-
|
| 1063 |
-
async def edit_message(
|
| 1064 |
-
self,
|
| 1065 |
-
conversation_id: str,
|
| 1066 |
-
message_id: str,
|
| 1067 |
-
user_id: str,
|
| 1068 |
-
new_content: str,
|
| 1069 |
-
) -> dict:
|
| 1070 |
-
"""
|
| 1071 |
-
Edit a message content.
|
| 1072 |
-
Only the sender can edit, within 24 hours, text messages only.
|
| 1073 |
-
"""
|
| 1074 |
-
db = await get_db()
|
| 1075 |
-
|
| 1076 |
-
# Validate IDs
|
| 1077 |
-
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 1078 |
-
raise HTTPException(
|
| 1079 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1080 |
-
detail="Invalid ID format"
|
| 1081 |
-
)
|
| 1082 |
-
|
| 1083 |
-
# Get the message
|
| 1084 |
-
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 1085 |
-
if not message_doc:
|
| 1086 |
-
raise HTTPException(
|
| 1087 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 1088 |
-
detail="Message not found"
|
| 1089 |
-
)
|
| 1090 |
-
|
| 1091 |
-
# Verify sender
|
| 1092 |
-
if message_doc.get("sender_id") != user_id:
|
| 1093 |
-
raise HTTPException(
|
| 1094 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1095 |
-
detail="You can only edit your own messages"
|
| 1096 |
-
)
|
| 1097 |
-
|
| 1098 |
-
# Verify message is in the correct conversation
|
| 1099 |
-
if message_doc.get("conversation_id") != conversation_id:
|
| 1100 |
-
raise HTTPException(
|
| 1101 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1102 |
-
detail="Message does not belong to this conversation"
|
| 1103 |
-
)
|
| 1104 |
-
|
| 1105 |
-
# Only allow editing text messages
|
| 1106 |
-
if message_doc.get("message_type") != "text":
|
| 1107 |
-
raise HTTPException(
|
| 1108 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1109 |
-
detail="Only text messages can be edited"
|
| 1110 |
-
)
|
| 1111 |
-
|
| 1112 |
-
# Check 15-minute edit window
|
| 1113 |
-
created_at = message_doc.get("created_at")
|
| 1114 |
-
if created_at:
|
| 1115 |
-
minutes_since = (datetime.utcnow() - created_at).total_seconds() / 60
|
| 1116 |
-
if minutes_since > 15:
|
| 1117 |
-
raise HTTPException(
|
| 1118 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1119 |
-
detail="Edit window expired (15 minutes)"
|
| 1120 |
-
)
|
| 1121 |
-
|
| 1122 |
-
# Cannot edit deleted messages
|
| 1123 |
-
if message_doc.get("is_deleted"):
|
| 1124 |
-
raise HTTPException(
|
| 1125 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1126 |
-
detail="Cannot edit a deleted message"
|
| 1127 |
-
)
|
| 1128 |
-
|
| 1129 |
-
# Update the message
|
| 1130 |
-
now = datetime.utcnow()
|
| 1131 |
-
await db.messages.update_one(
|
| 1132 |
-
{"_id": ObjectId(message_id)},
|
| 1133 |
-
{
|
| 1134 |
-
"$set": {
|
| 1135 |
-
"content": new_content.strip(),
|
| 1136 |
-
"is_edited": True,
|
| 1137 |
-
"edited_at": now,
|
| 1138 |
-
}
|
| 1139 |
-
}
|
| 1140 |
-
)
|
| 1141 |
-
|
| 1142 |
-
# Broadcast message_edited event to all participants via WebSocket
|
| 1143 |
-
try:
|
| 1144 |
-
from app.routes.websocket_chat import chat_manager
|
| 1145 |
-
|
| 1146 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 1147 |
-
if conversation:
|
| 1148 |
-
edit_event = {
|
| 1149 |
-
"action": "message_edited",
|
| 1150 |
-
"conversation_id": conversation_id,
|
| 1151 |
-
"message_id": message_id,
|
| 1152 |
-
"new_content": new_content.strip(),
|
| 1153 |
-
"edited_at": now.isoformat(),
|
| 1154 |
-
"edited_by": user_id,
|
| 1155 |
-
}
|
| 1156 |
-
|
| 1157 |
-
await chat_manager.broadcast_to_conversation(
|
| 1158 |
-
conversation_id,
|
| 1159 |
-
conversation.get("participants", []),
|
| 1160 |
-
edit_event
|
| 1161 |
-
)
|
| 1162 |
-
|
| 1163 |
-
logger.info(f"Broadcasted message_edited event for message {message_id}")
|
| 1164 |
-
except Exception as e:
|
| 1165 |
-
logger.warning(f"Failed to broadcast message_edited event: {e}")
|
| 1166 |
-
|
| 1167 |
-
logger.info(f"Message {message_id} edited by {user_id}")
|
| 1168 |
-
|
| 1169 |
-
return {
|
| 1170 |
-
"success": True,
|
| 1171 |
-
"message_id": message_id,
|
| 1172 |
-
"new_content": new_content.strip(),
|
| 1173 |
-
"edited_at": now.isoformat(),
|
| 1174 |
-
}
|
| 1175 |
-
|
| 1176 |
-
async def delete_message(
|
| 1177 |
-
self,
|
| 1178 |
-
conversation_id: str,
|
| 1179 |
-
message_id: str,
|
| 1180 |
-
user_id: str,
|
| 1181 |
-
delete_for: str = "me", # "everyone" or "me"
|
| 1182 |
-
) -> dict:
|
| 1183 |
-
"""
|
| 1184 |
-
Delete a message.
|
| 1185 |
-
- "everyone": Only sender, within 1 hour
|
| 1186 |
-
- "me": Any participant
|
| 1187 |
-
"""
|
| 1188 |
-
db = await get_db()
|
| 1189 |
-
|
| 1190 |
-
# Validate IDs
|
| 1191 |
-
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 1192 |
-
raise HTTPException(
|
| 1193 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1194 |
-
detail="Invalid ID format"
|
| 1195 |
-
)
|
| 1196 |
-
|
| 1197 |
-
# Get the message
|
| 1198 |
-
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 1199 |
-
if not message_doc:
|
| 1200 |
-
raise HTTPException(
|
| 1201 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 1202 |
-
detail="Message not found"
|
| 1203 |
-
)
|
| 1204 |
-
|
| 1205 |
-
# Verify message is in the correct conversation
|
| 1206 |
-
if message_doc.get("conversation_id") != conversation_id:
|
| 1207 |
-
raise HTTPException(
|
| 1208 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1209 |
-
detail="Message does not belong to this conversation"
|
| 1210 |
-
)
|
| 1211 |
-
|
| 1212 |
-
# Verify user is a participant
|
| 1213 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 1214 |
-
if not conversation or user_id not in conversation.get("participants", []):
|
| 1215 |
-
raise HTTPException(
|
| 1216 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1217 |
-
detail="You are not a participant in this conversation"
|
| 1218 |
-
)
|
| 1219 |
-
|
| 1220 |
-
now = datetime.utcnow()
|
| 1221 |
-
|
| 1222 |
-
if delete_for == "everyone":
|
| 1223 |
-
# Only sender can delete for everyone
|
| 1224 |
-
if message_doc.get("sender_id") != user_id:
|
| 1225 |
-
raise HTTPException(
|
| 1226 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1227 |
-
detail="Only the sender can delete for everyone"
|
| 1228 |
-
)
|
| 1229 |
-
|
| 1230 |
-
# Check 1-hour delete window
|
| 1231 |
-
created_at = message_doc.get("created_at")
|
| 1232 |
-
if created_at:
|
| 1233 |
-
hours_since = (datetime.utcnow() - created_at).total_seconds() / 3600
|
| 1234 |
-
if hours_since > 1:
|
| 1235 |
-
raise HTTPException(
|
| 1236 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1237 |
-
detail="Delete-for-everyone window expired (1 hour)"
|
| 1238 |
-
)
|
| 1239 |
-
|
| 1240 |
-
# Mark as deleted for everyone
|
| 1241 |
-
await db.messages.update_one(
|
| 1242 |
-
{"_id": ObjectId(message_id)},
|
| 1243 |
-
{
|
| 1244 |
-
"$set": {
|
| 1245 |
-
"is_deleted": True,
|
| 1246 |
-
"deleted_at": now,
|
| 1247 |
-
}
|
| 1248 |
-
}
|
| 1249 |
-
)
|
| 1250 |
-
|
| 1251 |
-
# Broadcast message_deleted event to all participants via WebSocket
|
| 1252 |
-
try:
|
| 1253 |
-
from app.routes.websocket_chat import chat_manager
|
| 1254 |
-
|
| 1255 |
-
delete_event = {
|
| 1256 |
-
"action": "message_deleted",
|
| 1257 |
-
"conversation_id": conversation_id,
|
| 1258 |
-
"message_id": message_id,
|
| 1259 |
-
"deleted_for": "everyone",
|
| 1260 |
-
"deleted_at": now.isoformat(),
|
| 1261 |
-
"deleted_by": user_id,
|
| 1262 |
-
}
|
| 1263 |
-
|
| 1264 |
-
await chat_manager.broadcast_to_conversation(
|
| 1265 |
-
conversation_id,
|
| 1266 |
-
conversation.get("participants", []),
|
| 1267 |
-
delete_event
|
| 1268 |
-
)
|
| 1269 |
-
|
| 1270 |
-
logger.info(f"Broadcasted message_deleted event for message {message_id}")
|
| 1271 |
-
except Exception as e:
|
| 1272 |
-
logger.warning(f"Failed to broadcast message_deleted event: {e}")
|
| 1273 |
-
|
| 1274 |
-
logger.info(f"Message {message_id} deleted for everyone by {user_id}")
|
| 1275 |
-
|
| 1276 |
-
return {
|
| 1277 |
-
"success": True,
|
| 1278 |
-
"message_id": message_id,
|
| 1279 |
-
"deleted_for": "everyone",
|
| 1280 |
-
"deleted_at": now.isoformat(),
|
| 1281 |
-
}
|
| 1282 |
-
|
| 1283 |
-
else: # delete_for == "me"
|
| 1284 |
-
# Add user to deleted_for list
|
| 1285 |
-
await db.messages.update_one(
|
| 1286 |
-
{"_id": ObjectId(message_id)},
|
| 1287 |
-
{"$addToSet": {"deleted_for": user_id}}
|
| 1288 |
-
)
|
| 1289 |
-
|
| 1290 |
-
logger.info(f"Message {message_id} deleted for {user_id} only")
|
| 1291 |
-
|
| 1292 |
-
return {
|
| 1293 |
-
"success": True,
|
| 1294 |
-
"message_id": message_id,
|
| 1295 |
-
"deleted_for": "me",
|
| 1296 |
-
"deleted_at": now.isoformat(),
|
| 1297 |
-
}
|
| 1298 |
-
|
| 1299 |
-
async def add_reaction(
|
| 1300 |
-
self,
|
| 1301 |
-
conversation_id: str,
|
| 1302 |
-
message_id: str,
|
| 1303 |
-
user_id: str,
|
| 1304 |
-
emoji: str,
|
| 1305 |
-
) -> dict:
|
| 1306 |
-
"""Add an emoji reaction to a message."""
|
| 1307 |
-
db = await get_db()
|
| 1308 |
-
|
| 1309 |
-
# Validate IDs
|
| 1310 |
-
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 1311 |
-
raise HTTPException(
|
| 1312 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1313 |
-
detail="Invalid ID format"
|
| 1314 |
-
)
|
| 1315 |
-
|
| 1316 |
-
# Get the message
|
| 1317 |
-
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 1318 |
-
if not message_doc:
|
| 1319 |
-
raise HTTPException(
|
| 1320 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 1321 |
-
detail="Message not found"
|
| 1322 |
-
)
|
| 1323 |
-
|
| 1324 |
-
# Verify message is in the correct conversation
|
| 1325 |
-
if message_doc.get("conversation_id") != conversation_id:
|
| 1326 |
-
raise HTTPException(
|
| 1327 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1328 |
-
detail="Message does not belong to this conversation"
|
| 1329 |
-
)
|
| 1330 |
-
|
| 1331 |
-
# Verify user is a participant
|
| 1332 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 1333 |
-
if not conversation or user_id not in conversation.get("participants", []):
|
| 1334 |
-
raise HTTPException(
|
| 1335 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1336 |
-
detail="You are not a participant in this conversation"
|
| 1337 |
-
)
|
| 1338 |
-
|
| 1339 |
-
# Cannot react to deleted messages
|
| 1340 |
-
if message_doc.get("is_deleted"):
|
| 1341 |
-
raise HTTPException(
|
| 1342 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1343 |
-
detail="Cannot react to a deleted message"
|
| 1344 |
-
)
|
| 1345 |
-
|
| 1346 |
-
# Add reaction
|
| 1347 |
-
await db.messages.update_one(
|
| 1348 |
-
{"_id": ObjectId(message_id)},
|
| 1349 |
-
{"$addToSet": {f"reactions.{emoji}": user_id}}
|
| 1350 |
-
)
|
| 1351 |
-
|
| 1352 |
-
# Broadcast reaction_added event to all participants via WebSocket
|
| 1353 |
-
try:
|
| 1354 |
-
from app.routes.websocket_chat import chat_manager
|
| 1355 |
-
|
| 1356 |
-
reaction_event = {
|
| 1357 |
-
"action": "reaction_added",
|
| 1358 |
-
"conversation_id": conversation_id,
|
| 1359 |
-
"message_id": message_id,
|
| 1360 |
-
"emoji": emoji,
|
| 1361 |
-
"user_id": user_id,
|
| 1362 |
-
}
|
| 1363 |
-
|
| 1364 |
-
await chat_manager.broadcast_to_conversation(
|
| 1365 |
-
conversation_id,
|
| 1366 |
-
conversation.get("participants", []),
|
| 1367 |
-
reaction_event
|
| 1368 |
-
)
|
| 1369 |
-
|
| 1370 |
-
logger.info(f"Broadcasted reaction_added event for message {message_id}")
|
| 1371 |
-
except Exception as e:
|
| 1372 |
-
logger.warning(f"Failed to broadcast reaction_added event: {e}")
|
| 1373 |
-
|
| 1374 |
-
logger.info(f"Reaction {emoji} added to message {message_id} by {user_id}")
|
| 1375 |
-
|
| 1376 |
-
return {
|
| 1377 |
-
"success": True,
|
| 1378 |
-
"message_id": message_id,
|
| 1379 |
-
"emoji": emoji,
|
| 1380 |
-
"user_id": user_id,
|
| 1381 |
-
}
|
| 1382 |
-
|
| 1383 |
-
async def remove_reaction(
|
| 1384 |
-
self,
|
| 1385 |
-
conversation_id: str,
|
| 1386 |
-
message_id: str,
|
| 1387 |
-
user_id: str,
|
| 1388 |
-
emoji: str,
|
| 1389 |
-
) -> dict:
|
| 1390 |
-
"""Remove an emoji reaction from a message."""
|
| 1391 |
-
db = await get_db()
|
| 1392 |
-
|
| 1393 |
-
# Validate IDs
|
| 1394 |
-
if not ObjectId.is_valid(conversation_id) or not ObjectId.is_valid(message_id):
|
| 1395 |
-
raise HTTPException(
|
| 1396 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1397 |
-
detail="Invalid ID format"
|
| 1398 |
-
)
|
| 1399 |
-
|
| 1400 |
-
# Get the message
|
| 1401 |
-
message_doc = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 1402 |
-
if not message_doc:
|
| 1403 |
-
raise HTTPException(
|
| 1404 |
-
status_code=status.HTTP_404_NOT_FOUND,
|
| 1405 |
-
detail="Message not found"
|
| 1406 |
-
)
|
| 1407 |
-
|
| 1408 |
-
# Verify message is in the correct conversation
|
| 1409 |
-
if message_doc.get("conversation_id") != conversation_id:
|
| 1410 |
-
raise HTTPException(
|
| 1411 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1412 |
-
detail="Message does not belong to this conversation"
|
| 1413 |
-
)
|
| 1414 |
-
|
| 1415 |
-
# Verify user is a participant
|
| 1416 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 1417 |
-
if not conversation or user_id not in conversation.get("participants", []):
|
| 1418 |
-
raise HTTPException(
|
| 1419 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1420 |
-
detail="You are not a participant in this conversation"
|
| 1421 |
-
)
|
| 1422 |
-
|
| 1423 |
-
# Remove reaction
|
| 1424 |
-
await db.messages.update_one(
|
| 1425 |
-
{"_id": ObjectId(message_id)},
|
| 1426 |
-
{"$pull": {f"reactions.{emoji}": user_id}}
|
| 1427 |
-
)
|
| 1428 |
-
|
| 1429 |
-
# Clean up empty reaction arrays
|
| 1430 |
-
updated_msg = await db.messages.find_one({"_id": ObjectId(message_id)})
|
| 1431 |
-
if updated_msg:
|
| 1432 |
-
reactions = updated_msg.get("reactions", {})
|
| 1433 |
-
if emoji in reactions and len(reactions[emoji]) == 0:
|
| 1434 |
-
await db.messages.update_one(
|
| 1435 |
-
{"_id": ObjectId(message_id)},
|
| 1436 |
-
{"$unset": {f"reactions.{emoji}": ""}}
|
| 1437 |
-
)
|
| 1438 |
-
|
| 1439 |
-
# Broadcast reaction_removed event to all participants via WebSocket
|
| 1440 |
-
try:
|
| 1441 |
-
from app.routes.websocket_chat import chat_manager
|
| 1442 |
-
|
| 1443 |
-
reaction_event = {
|
| 1444 |
-
"action": "reaction_removed",
|
| 1445 |
-
"conversation_id": conversation_id,
|
| 1446 |
-
"message_id": message_id,
|
| 1447 |
-
"emoji": emoji,
|
| 1448 |
-
"user_id": user_id,
|
| 1449 |
-
}
|
| 1450 |
-
|
| 1451 |
-
await chat_manager.broadcast_to_conversation(
|
| 1452 |
-
conversation_id,
|
| 1453 |
-
conversation.get("participants", []),
|
| 1454 |
-
reaction_event
|
| 1455 |
-
)
|
| 1456 |
-
|
| 1457 |
-
logger.info(f"Broadcasted reaction_removed event for message {message_id}")
|
| 1458 |
-
except Exception as e:
|
| 1459 |
-
logger.warning(f"Failed to broadcast reaction_removed event: {e}")
|
| 1460 |
-
|
| 1461 |
-
logger.info(f"Reaction {emoji} removed from message {message_id} by {user_id}")
|
| 1462 |
-
|
| 1463 |
-
return {
|
| 1464 |
-
"success": True,
|
| 1465 |
-
"message_id": message_id,
|
| 1466 |
-
"emoji": emoji,
|
| 1467 |
-
"user_id": user_id,
|
| 1468 |
-
}
|
| 1469 |
-
|
| 1470 |
-
async def clear_chat(
|
| 1471 |
-
self,
|
| 1472 |
-
conversation_id: str,
|
| 1473 |
-
user_id: str,
|
| 1474 |
-
) -> dict:
|
| 1475 |
-
"""
|
| 1476 |
-
Clear all messages in a conversation for the current user only.
|
| 1477 |
-
|
| 1478 |
-
Stores a cleared_at timestamp on the conversation document.
|
| 1479 |
-
Messages with created_at <= cleared_at won't be shown to this user,
|
| 1480 |
-
even after logout/login or on a new device.
|
| 1481 |
-
Other participants still see all messages.
|
| 1482 |
-
"""
|
| 1483 |
-
db = await get_db()
|
| 1484 |
-
|
| 1485 |
-
# Validate ID
|
| 1486 |
-
if not ObjectId.is_valid(conversation_id):
|
| 1487 |
-
raise HTTPException(
|
| 1488 |
-
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1489 |
-
detail="Invalid conversation ID format"
|
| 1490 |
-
)
|
| 1491 |
-
|
| 1492 |
-
# Verify user is a participant
|
| 1493 |
-
conversation = await db.conversations.find_one({"_id": ObjectId(conversation_id)})
|
| 1494 |
-
if not conversation or user_id not in conversation.get("participants", []):
|
| 1495 |
-
raise HTTPException(
|
| 1496 |
-
status_code=status.HTTP_403_FORBIDDEN,
|
| 1497 |
-
detail="You are not a participant in this conversation"
|
| 1498 |
-
)
|
| 1499 |
-
|
| 1500 |
-
now = datetime.utcnow()
|
| 1501 |
-
|
| 1502 |
-
# Store cleared_at timestamp on conversation for persistent filtering
|
| 1503 |
-
# This is the key change - ensures clear persists across sessions/devices
|
| 1504 |
-
await db.conversations.update_one(
|
| 1505 |
-
{"_id": ObjectId(conversation_id)},
|
| 1506 |
-
{"$set": {f"cleared_at.{user_id}": now}}
|
| 1507 |
-
)
|
| 1508 |
-
|
| 1509 |
-
# Also mark existing messages with deleted_for (backwards compatibility)
|
| 1510 |
-
# This provides immediate UI response and works with existing code
|
| 1511 |
-
result = await db.messages.update_many(
|
| 1512 |
-
{"conversation_id": conversation_id},
|
| 1513 |
-
{"$addToSet": {"deleted_for": user_id}}
|
| 1514 |
-
)
|
| 1515 |
-
|
| 1516 |
-
logger.info(f"Chat {conversation_id} cleared for {user_id} at {now.isoformat()} ({result.modified_count} messages)")
|
| 1517 |
-
|
| 1518 |
-
return {
|
| 1519 |
-
"success": True,
|
| 1520 |
-
"conversation_id": conversation_id,
|
| 1521 |
-
"cleared_count": result.modified_count,
|
| 1522 |
-
"cleared_at": now.isoformat(),
|
| 1523 |
-
}
|
| 1524 |
-
|
| 1525 |
|
| 1526 |
# Singleton instance
|
| 1527 |
conversation_service = ConversationService()
|
| 1528 |
-
|
|
|
|
| 3 |
# ============================================================
|
| 4 |
|
| 5 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
from app.services.conversation_parts import (
|
| 8 |
+
ConversationCRUDMixin,
|
| 9 |
+
ConversationMessageMixin,
|
| 10 |
+
ConversationActionMixin,
|
| 11 |
+
ConversationAIMixin
|
| 12 |
+
)
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
+
class ConversationService(
|
| 17 |
+
ConversationCRUDMixin,
|
| 18 |
+
ConversationMessageMixin,
|
| 19 |
+
ConversationActionMixin,
|
| 20 |
+
ConversationAIMixin
|
| 21 |
+
):
|
| 22 |
+
"""
|
| 23 |
+
Main Conversation Service.
|
| 24 |
+
|
| 25 |
+
Refactored into Mixins for maintainability:
|
| 26 |
+
- CRUD: Basic init, get lists, enrich participants
|
| 27 |
+
- Messages: Send, get, mark read
|
| 28 |
+
- Actions: Edit, delete, react, clear
|
| 29 |
+
- AI: AIDA integration
|
| 30 |
+
"""
|
| 31 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# Singleton instance
|
| 34 |
conversation_service = ConversationService()
|
|
|
app/services/redis_pubsub.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# app/services/redis_pubsub.py - Redis Pub/Sub for WebSockets
|
| 3 |
+
# ============================================================
|
| 4 |
+
#
|
| 5 |
+
# Enables horizontal scaling effectively by broadcasting WebSocket
|
| 6 |
+
# events across multiple server instances via Redis.
|
| 7 |
+
#
|
| 8 |
+
# Pattern: "Broadcast"
|
| 9 |
+
# 1. Any server can publish a message to the global channel
|
| 10 |
+
# 2. All servers subscribe to the channel
|
| 11 |
+
# 3. Each server checks if the target user(s) are connected locally
|
| 12 |
+
# 4. If connected, the server delivers the message via WebSocket
|
| 13 |
+
# ============================================================
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import asyncio
|
| 18 |
+
from typing import Callable, Any, Optional
|
| 19 |
+
from app.ai.config import redis_client
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
# Global channel name
|
| 24 |
+
CHAT_CHANNEL = "lojiz_chat_global"
|
| 25 |
+
|
| 26 |
+
class RedisPubSubService:
|
| 27 |
+
"""Service for handling Redis Pub/Sub operations"""
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
self.is_listening = False
|
| 31 |
+
self.message_handler: Optional[Callable[[dict], Any]] = None
|
| 32 |
+
|
| 33 |
+
async def publish(self, event_type: str, data: dict):
|
| 34 |
+
"""
|
| 35 |
+
Publish an event to the global Redis channel.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
event_type: "broadcast" (multiple users) or "direct" (single user)
|
| 39 |
+
data: The payload containing target_users and message
|
| 40 |
+
"""
|
| 41 |
+
if not redis_client:
|
| 42 |
+
logger.warning("Redis not available, pub/sub disabled")
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
payload = {
|
| 46 |
+
"type": event_type,
|
| 47 |
+
"data": data,
|
| 48 |
+
"source_server": id(self), # Simple way to identify sender (not robust across processes but helpful)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
await redis_client.publish(CHAT_CHANNEL, json.dumps(payload))
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Redis publish failed: {e}")
|
| 55 |
+
|
| 56 |
+
async def start_listening(self, handler: Callable[[dict], Any]):
|
| 57 |
+
"""
|
| 58 |
+
Start listening to the global channel in a background task.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
handler: Async function to process received messages
|
| 62 |
+
"""
|
| 63 |
+
if not redis_client:
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
self.message_handler = handler
|
| 67 |
+
self.is_listening = True
|
| 68 |
+
|
| 69 |
+
# Start background listener
|
| 70 |
+
asyncio.create_task(self._listener_loop())
|
| 71 |
+
logger.info(f"✅ Redis Pub/Sub listener started on channel: {CHAT_CHANNEL}")
|
| 72 |
+
|
| 73 |
+
async def _listener_loop(self):
|
| 74 |
+
"""Background loop to listen for Redis messages"""
|
| 75 |
+
pubsub = redis_client.pubsub()
|
| 76 |
+
await pubsub.subscribe(CHAT_CHANNEL)
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
async for message in pubsub.listen():
|
| 80 |
+
if not self.is_listening:
|
| 81 |
+
break
|
| 82 |
+
|
| 83 |
+
if message["type"] == "message":
|
| 84 |
+
try:
|
| 85 |
+
payload = json.loads(message["data"])
|
| 86 |
+
# Call the handler with the payload
|
| 87 |
+
if self.message_handler:
|
| 88 |
+
await self.message_handler(payload)
|
| 89 |
+
except json.JSONDecodeError:
|
| 90 |
+
logger.warning("Received invalid JSON in Redis message")
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logger.error(f"Error processing Redis message: {e}")
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Redis listener loop crashed: {e}")
|
| 96 |
+
# Retry logic could go here
|
| 97 |
+
finally:
|
| 98 |
+
await pubsub.unsubscribe(CHAT_CHANNEL)
|
| 99 |
+
await pubsub.close()
|
| 100 |
+
|
| 101 |
+
# Singleton instance
|
| 102 |
+
redis_pubsub = RedisPubSubService()
|
main.py
CHANGED
|
@@ -5,6 +5,10 @@ from fastapi import FastAPI, Request
|
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from fastapi.exceptions import RequestValidationError
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
import logging
|
| 10 |
import os
|
|
@@ -120,6 +124,14 @@ async def lifespan(app: FastAPI):
|
|
| 120 |
except Exception as e:
|
| 121 |
logger.warning(f"⚠️ Memory Manager initialization warning: {e}")
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
logger.info("=" * 70)
|
| 124 |
logger.info("✅ APPLICATION READY - Graph-Based Architecture Active!")
|
| 125 |
logger.info("=" * 70)
|
|
@@ -160,6 +172,11 @@ app = FastAPI(
|
|
| 160 |
lifespan=lifespan,
|
| 161 |
)
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
# CORS
|
| 164 |
cors_origins = [
|
| 165 |
"https://lojiz.onrender.com",
|
|
|
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from fastapi.exceptions import RequestValidationError
|
| 8 |
+
from slowapi import _rate_limit_exceeded_handler
|
| 9 |
+
from slowapi.errors import RateLimitExceeded
|
| 10 |
+
from slowapi.middleware import SlowAPIMiddleware
|
| 11 |
+
from app.middleware import limiter, rate_limit_exceeded_handler
|
| 12 |
from contextlib import asynccontextmanager
|
| 13 |
import logging
|
| 14 |
import os
|
|
|
|
| 124 |
except Exception as e:
|
| 125 |
logger.warning(f"⚠️ Memory Manager initialization warning: {e}")
|
| 126 |
|
| 127 |
+
# Initialize Distributed Chat (Redis Pub/Sub)
|
| 128 |
+
try:
|
| 129 |
+
from app.routes.websocket_chat import chat_manager
|
| 130 |
+
await chat_manager.initialize()
|
| 131 |
+
logger.info("✅ Redis Pub/Sub for Chat initialized")
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.warning(f"⚠️ Chat manager init failed: {e}")
|
| 134 |
+
|
| 135 |
logger.info("=" * 70)
|
| 136 |
logger.info("✅ APPLICATION READY - Graph-Based Architecture Active!")
|
| 137 |
logger.info("=" * 70)
|
|
|
|
| 172 |
lifespan=lifespan,
|
| 173 |
)
|
| 174 |
|
| 175 |
+
# RATE LIMITING
|
| 176 |
+
app.state.limiter = limiter
|
| 177 |
+
app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler)
|
| 178 |
+
app.add_middleware(SlowAPIMiddleware)
|
| 179 |
+
|
| 180 |
# CORS
|
| 181 |
cors_origins = [
|
| 182 |
"https://lojiz.onrender.com",
|
requirements.txt
CHANGED
|
@@ -12,6 +12,7 @@ charset-normalizer>=3.2.0
|
|
| 12 |
|
| 13 |
# --- FastAPI & Web Framework ---
|
| 14 |
fastapi==0.104.1
|
|
|
|
| 15 |
uvicorn[standard]==0.24.0
|
| 16 |
python-multipart==0.0.6
|
| 17 |
|
|
|
|
| 12 |
|
| 13 |
# --- FastAPI & Web Framework ---
|
| 14 |
fastapi==0.104.1
|
| 15 |
+
slowapi>=0.1.9 # Rate Limiting
|
| 16 |
uvicorn[standard]==0.24.0
|
| 17 |
python-multipart==0.0.6
|
| 18 |
|