destinyebuka commited on
Commit
ee7602c
·
1 Parent(s): 310bac1
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
- async def ask_ai(body: AskBody) -> AgentResponse:
 
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. Invoke graph with dict input and high recursion_limit
62
- 4. Extract final_response from returned dict
63
- 5. Return to client
64
  """
65
 
66
- logger.info("🚀 Chat request received", message_len=len(body.message))
 
 
67
 
68
- try:
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
- message = body.message.strip()
83
- session_id = body.session_id or str(uuid4())
84
- user_id = body.user_id or f"anonymous_{uuid4()}"
85
- user_role = body.user_role or "renter"
86
-
87
- logger.info(
88
- "📋 User session info",
89
- user_id=user_id,
90
- session_id=session_id,
91
- user_role=user_role,
92
- message_len=len(message),
93
- )
94
-
95
- # ============================================================
96
- # STEP 2: Build input dict for graph
97
- # ============================================================
98
- # ✅ CRITICAL: Pass dict, not AgentState
99
-
100
- input_dict = {
101
- "user_id": user_id,
102
- "session_id": session_id,
103
- "user_role": user_role,
104
- "user_name": body.user_name,
105
- "user_location": body.user_location,
106
- "last_user_message": message,
107
- # "conversation_history": [], <-- REMOVED: Do not overwrite history!
108
- "language_detected": "en",
109
- "start_new_session": body.start_new_session or False,
110
- "is_voice_message": body.is_voice_message or False, # Track voice input
111
- "source": body.source or "default",
112
- # Store reply_context in temp_data so brain can access it
113
- "temp_data": {"reply_context": body.reply_context} if body.reply_context else {},
114
- }
115
 
116
  # Only initialize history if starting new session
117
- if body.start_new_session:
118
- input_dict["conversation_history"] = []
119
- input_dict["provided_fields"] = {}
120
- input_dict["missing_required_fields"] = []
121
- logger.info("🆕 Starting NEW session (clearing state)")
 
 
 
122
 
123
- logger.info("📦 Input dict prepared", keys=list(input_dict.keys()))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  # ============================================================
126
- # STEP 3: Get graph and validate
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
- final_state_dict = await graph.ainvoke(
167
- input_dict,
168
- config=config
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
- except HTTPException:
262
- raise
263
- except Exception as e:
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(settings.MONGODB_URL)
 
 
 
 
 
 
 
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 send_to_user(self, user_id: str, message: dict):
375
- """Send message to all of a user's connections"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return True
 
 
 
 
 
 
 
 
 
 
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.send_to_user(user_id, message)
409
  if sent_ok and user_id != sender_id:
410
  delivered_users.append(user_id)
411
 
412
- # If we have message_id and delivered users, update delivered_to in DB
 
 
 
 
 
 
 
 
 
 
 
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 that message was delivered
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.database import get_db
12
- from app.models.conversation import Conversation
13
- from app.models.message import Message
14
- from app.models.listing import Listing
15
- from app.routes.websocket_chat import chat_manager # For WebSocket notifications
 
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
-
20
- class ConversationService:
21
- """Service for handling conversation operations"""
22
-
23
- async def start_or_get_conversation(
24
- self,
25
- listing_id: str,
26
- current_user_id: str,
27
- initial_message: Optional[str] = None,
28
- ) -> dict:
29
- """
30
- Start a new conversation or get existing one between two users.
31
-
32
- KEY BEHAVIOR (Updated):
33
- - One conversation per user pair (NOT per listing)
34
- - If User A and User B already have a conversation, return it
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