AIDA / app /services /aida_dm_service.py
destinyebuka's picture
fyp
d14d290
# ============================================================
# 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