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