Spaces:
Running
Running
File size: 7,552 Bytes
85625af d14d290 85625af d14d290 85625af 7cd10a9 85625af 7cd10a9 85625af 7cd10a9 85625af 7cd10a9 85625af 7cd10a9 85625af d14d290 85625af 7cd10a9 85625af 7cd10a9 85625af 7cd10a9 85625af | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | # ============================================================
# 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
|