Spaces:
Running
Running
| # ============================================================ | |
| # 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 | |