# ============================================================ # app/services/aida_dm_service.py # # Centralized helper for sending AIDA-initiated DMs. # Every call does THREE things atomically: # 1. Insert message into MongoDB # 2. Broadcast via WebSocket → in-app notification banner fires # 3. FCM push to recipient → works even when user is offline # # Canonical AIDA participant ID: "AIDA_BOT" # - Matches Flutter's DirectMessage.isAi check (senderId == 'AIDA_BOT') # - Matches booking.py, conversations.py, landlord_notifications.py # - Never creates a duplicate conversation — delegates to # get_or_create_aida_conversation() which has full multi-step # lookup + legacy "aida" → "AIDA_BOT" migration built in. # # Usage: # from app.services.aida_dm_service import send_aida_dm # await send_aida_dm(user_id="abc123", text="Hello!", metadata={...}) # ============================================================ import structlog from datetime import datetime from bson import ObjectId from app.database import get_db from app.services.push_service import push_service logger = structlog.get_logger(__name__) AIDA_BOT_ID = "AIDA_BOT" # canonical participant ID — must match Flutter & all routes AIDA_AVATAR = ( "https://imagedelivery.net/0utJlkqgAVuawL5OpMWxgw/" "3922956f-b69d-4cb3-97b9-3a185abec900/public" ) def _build_message_doc(conv_id: str, text: str, metadata: dict | None) -> dict: """Build the raw MongoDB document for an AIDA-sent message.""" return { "conversation_id": conv_id, "sender_id": AIDA_BOT_ID, # Flutter checks senderId == 'AIDA_BOT' "sender_name": "AIDA", "sender_avatar": AIDA_AVATAR, "message_type": "ai", "content": text, "metadata": metadata or {}, "is_read": False, "is_edited": False, "is_deleted": False, "reactions": {}, "created_at": datetime.utcnow(), } def _format_for_ws(msg_doc: dict) -> dict: """Convert the raw MongoDB doc to the shape Flutter's DirectMessage.fromJson expects.""" return { "id": str(msg_doc["_id"]), "conversation_id": msg_doc["conversation_id"], "sender_id": msg_doc["sender_id"], "sender_name": msg_doc.get("sender_name", "AIDA"), "sender_avatar": msg_doc.get("sender_avatar"), "message_type": msg_doc.get("message_type", "ai"), "content": msg_doc.get("content", ""), "metadata": msg_doc.get("metadata", {}), "is_read": False, "is_edited": False, "is_deleted": False, "reactions": {}, "created_at": msg_doc["created_at"].isoformat(), } async def send_aida_dm( user_id: str, text: str, metadata: dict | None = None, *, push_title: str = "AIDA", push_body: str | None = None, conv_id: str | None = None, ) -> str: """ Send an AIDA DM to *user_id*. Steps: 1. Resolve the EXISTING AIDA conversation for this user (create only if none exists). Uses get_or_create_aida_conversation() which: - Looks for AIDA_BOT participant first (canonical) - Falls back to participants_key lookup - Migrates legacy "aida" conversations to AIDA_BOT in-place - Only creates a new conversation as last resort 2. Insert message into `messages` collection. 3. Update conversation preview. 4. Broadcast `new_message` WebSocket event (triggers in-app banner). 5. Send FCM push notification (triggers system notification when offline). Returns the conversation ID. """ db = await get_db() # 1. Resolve conversation — reuse canonical lookup, never duplicate if conv_id is None: from app.services.landlord_notifications import get_or_create_aida_conversation conv_id = await get_or_create_aida_conversation(db, user_id) # 2. Insert message doc = _build_message_doc(conv_id, text, metadata) result = await db["messages"].insert_one(doc) doc["_id"] = result.inserted_id # 3. Update conversation preview await db["conversations"].update_one( {"_id": ObjectId(conv_id)}, { "$set": { "updated_at": datetime.utcnow(), "last_message": text[:120], "last_sender_id": AIDA_BOT_ID, } }, ) # 4. WebSocket broadcast — lazy import to avoid circular dependency try: from app.routes.websocket_chat import chat_manager # type: ignore participants = [user_id, AIDA_BOT_ID] payload = { "action": "new_message", "conversation_id": conv_id, "message": _format_for_ws(doc), } await chat_manager.broadcast_to_conversation(conv_id, participants, payload) logger.debug(f"[AIDA DM] WS broadcast sent for conv {conv_id}") except Exception as exc: logger.warning(f"[AIDA DM] WS broadcast failed: {exc}") # 5. Notification side — go through the unified dispatcher (#19) so: # - the row lands in the persistent inbox (#18, bell badge picks it up) # - WS-or-push routing decides (no duplicate banner+tray when online) # - same notification_id flows through every surface for dedup # - email is intentionally off here (an AIDA reply isn't an email-worthy event) # # The new_message WS broadcast above (step 4) handles the actual # message-bubble delivery, which is a separate concern from the # notification surface. Those two events are complementary, not # redundant. try: from app.services.notification_dispatcher import dispatch_notification from app.services.notification_inbox_service import NotificationCategory body = push_body or (text[:100] + "…" if len(text) > 100 else text) await dispatch_notification( user_id=user_id, category=NotificationCategory.AIDA_REPLY, title=push_title, body=body, deep_link="aida_dm", data={ "notification_type": "aida_message", # Flutter _buildPayload routes on this "sender_name": "AIDA", "avatar_url": AIDA_AVATAR, "conversation_id": conv_id, }, push=True, email=False, ) logger.debug(f"[AIDA DM] Notification dispatched for user {user_id}") except Exception as exc: logger.warning(f"[AIDA DM] Dispatcher failed: {exc}") # Fall back to direct push so the user still sees an alert when # the dispatcher is unavailable. try: body = push_body or (text[:100] + "…" if len(text) > 100 else text) await push_service.send_to_user( user_id=user_id, title=push_title, body=body, category="messages", data={ "category": "messages", "sender_name": "AIDA", "avatar_url": AIDA_AVATAR, "route": "/direct_chat", "conversation_id": conv_id, }, ) except Exception as exc2: logger.warning(f"[AIDA DM] Direct push fallback also failed: {exc2}") return conv_id