from __future__ import annotations import re from typing import Any from asgiref.sync import sync_to_async from core.messaging import history from core.models import Message def _as_dict(value: Any) -> dict[str, Any]: return dict(value) if isinstance(value, dict) else {} def _pluck(data: Any, *path: str): cur = data for key in path: if isinstance(cur, dict): cur = cur.get(key) continue if hasattr(cur, key): cur = getattr(cur, key) continue return None return cur def _clean(value: Any) -> str: return str(value or "").strip() def _find_origin_tag(value: Any, depth: int = 0) -> str: if depth > 4: return "" if isinstance(value, dict): direct = _clean(value.get("origin_tag")) if direct: return direct for key in ("metadata", "meta", "message_meta", "contextInfo", "context_info"): nested = _find_origin_tag(value.get(key), depth + 1) if nested: return nested for nested_value in value.values(): nested = _find_origin_tag(nested_value, depth + 1) if nested: return nested return "" if isinstance(value, list): for item in value: nested = _find_origin_tag(item, depth + 1) if nested: return nested return "" def _extract_signal_reply(raw_payload: dict[str, Any]) -> dict[str, str]: envelope = _as_dict((raw_payload or {}).get("envelope")) sync_message = _as_dict(envelope.get("syncMessage")) sent_message = _as_dict(sync_message.get("sentMessage")) data_candidates = [ _as_dict(envelope.get("dataMessage")), _as_dict(sent_message.get("message")), _as_dict(sent_message), _as_dict((raw_payload or {}).get("dataMessage")), _as_dict(raw_payload), ] quote_key_candidates = ( "id", "targetSentTimestamp", "targetTimestamp", "quotedMessageId", "quoted_message_id", "quotedMessageID", "messageId", "message_id", "timestamp", ) quote_author_candidates = ( "author", "authorUuid", "authorAci", "authorNumber", "source", "sourceNumber", "sourceUuid", ) quote_candidates: list[dict[str, Any]] = [] for data_message in data_candidates: if not data_message: continue direct_quote = _as_dict(data_message.get("quote") or data_message.get("Quote")) if direct_quote: quote_candidates.append(direct_quote) stack = [data_message] while stack: current = stack.pop() if not isinstance(current, dict): continue for key, value in current.items(): if isinstance(value, dict): key_text = str(key or "").strip().lower() if "quote" in key_text or "reply" in key_text: quote_candidates.append(_as_dict(value)) stack.append(value) elif isinstance(value, list): for item in value: if isinstance(item, dict): stack.append(item) for quote in quote_candidates: quote_id = "" for key in quote_key_candidates: quote_id = _clean(quote.get(key)) if quote_id: break if not quote_id: nested = _as_dict(quote.get("id")) if nested: for key in quote_key_candidates: quote_id = _clean(nested.get(key)) if quote_id: break if quote_id: reply_chat_id = "" for key in quote_author_candidates: reply_chat_id = _clean(quote.get(key)) if reply_chat_id: break return { "reply_source_message_id": quote_id, "reply_source_service": "signal", "reply_source_chat_id": reply_chat_id, } return {} def _extract_whatsapp_reply(raw_payload: dict[str, Any]) -> dict[str, str]: # Handles common and nested contextInfo/messageContextInfo shapes for # WhatsApp payloads (extended text, media, ephemeral, view-once wrappers). candidate_paths = ( ("contextInfo",), ("ContextInfo",), ("messageContextInfo",), ("MessageContextInfo",), ("extendedTextMessage", "contextInfo"), ("ExtendedTextMessage", "ContextInfo"), ("imageMessage", "contextInfo"), ("ImageMessage", "ContextInfo"), ("videoMessage", "contextInfo"), ("VideoMessage", "ContextInfo"), ("documentMessage", "contextInfo"), ("DocumentMessage", "ContextInfo"), ("ephemeralMessage", "message", "contextInfo"), ("ephemeralMessage", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessage", "message", "contextInfo"), ("viewOnceMessage", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessageV2", "message", "contextInfo"), ("viewOnceMessageV2", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessageV2Extension", "message", "contextInfo"), ("viewOnceMessageV2Extension", "message", "extendedTextMessage", "contextInfo"), # snake_case protobuf dict variants ("context_info",), ("message_context_info",), ("extended_text_message", "context_info"), ("image_message", "context_info"), ("video_message", "context_info"), ("document_message", "context_info"), ("ephemeral_message", "message", "context_info"), ("ephemeral_message", "message", "extended_text_message", "context_info"), ("view_once_message", "message", "context_info"), ("view_once_message", "message", "extended_text_message", "context_info"), ("view_once_message_v2", "message", "context_info"), ("view_once_message_v2", "message", "extended_text_message", "context_info"), ("view_once_message_v2_extension", "message", "context_info"), ( "view_once_message_v2_extension", "message", "extended_text_message", "context_info", ), ) contexts = [] for path in candidate_paths: row = _as_dict(_pluck(raw_payload, *path)) if row: contexts.append(row) # Recursive fallback for unknown wrapper shapes. stack = [_as_dict(raw_payload)] while stack: current = stack.pop() if not isinstance(current, dict): continue if isinstance(current.get("contextInfo"), dict): contexts.append(_as_dict(current.get("contextInfo"))) if isinstance(current.get("ContextInfo"), dict): contexts.append(_as_dict(current.get("ContextInfo"))) if isinstance(current.get("messageContextInfo"), dict): contexts.append(_as_dict(current.get("messageContextInfo"))) if isinstance(current.get("MessageContextInfo"), dict): contexts.append(_as_dict(current.get("MessageContextInfo"))) if isinstance(current.get("context_info"), dict): contexts.append(_as_dict(current.get("context_info"))) if isinstance(current.get("message_context_info"), dict): contexts.append(_as_dict(current.get("message_context_info"))) for value in current.values(): if isinstance(value, dict): stack.append(value) elif isinstance(value, list): for item in value: if isinstance(item, dict): stack.append(item) for context in contexts: stanza_id = _clean( context.get("stanzaId") or context.get("stanzaID") or context.get("stanza_id") or context.get("StanzaId") or context.get("StanzaID") or context.get("quotedMessageID") or context.get("quotedMessageId") or context.get("QuotedMessageID") or context.get("QuotedMessageId") or _pluck(context, "quotedMessageKey", "id") or _pluck(context, "quoted_message_key", "id") or _pluck(context, "quotedMessage", "key", "id") or _pluck(context, "quoted_message", "key", "id") ) if not stanza_id: continue participant = _clean( context.get("participant") or context.get("remoteJid") or context.get("chat") or context.get("Participant") or context.get("RemoteJid") or context.get("RemoteJID") or context.get("Chat") ) return { "reply_source_message_id": stanza_id, "reply_source_service": "whatsapp", "reply_source_chat_id": participant, } return {} def extract_whatsapp_reply_debug(raw_payload: dict[str, Any]) -> dict[str, Any]: payload = _as_dict(raw_payload) candidate_paths = ( ("contextInfo",), ("ContextInfo",), ("messageContextInfo",), ("MessageContextInfo",), ("extendedTextMessage", "contextInfo"), ("ExtendedTextMessage", "ContextInfo"), ("imageMessage", "contextInfo"), ("ImageMessage", "ContextInfo"), ("videoMessage", "contextInfo"), ("VideoMessage", "ContextInfo"), ("documentMessage", "contextInfo"), ("DocumentMessage", "ContextInfo"), ("ephemeralMessage", "message", "contextInfo"), ("ephemeralMessage", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessage", "message", "contextInfo"), ("viewOnceMessage", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessageV2", "message", "contextInfo"), ("viewOnceMessageV2", "message", "extendedTextMessage", "contextInfo"), ("viewOnceMessageV2Extension", "message", "contextInfo"), ("viewOnceMessageV2Extension", "message", "extendedTextMessage", "contextInfo"), ("context_info",), ("message_context_info",), ("extended_text_message", "context_info"), ("image_message", "context_info"), ("video_message", "context_info"), ("document_message", "context_info"), ("ephemeral_message", "message", "context_info"), ("ephemeral_message", "message", "extended_text_message", "context_info"), ("view_once_message", "message", "context_info"), ("view_once_message", "message", "extended_text_message", "context_info"), ("view_once_message_v2", "message", "context_info"), ("view_once_message_v2", "message", "extended_text_message", "context_info"), ("view_once_message_v2_extension", "message", "context_info"), ( "view_once_message_v2_extension", "message", "extended_text_message", "context_info", ), ) rows = [] for path in candidate_paths: context = _as_dict(_pluck(payload, *path)) if not context: continue rows.append( { "path": ".".join(path), "keys": sorted([str(key) for key in context.keys()])[:40], "stanzaId": _clean( context.get("stanzaId") or context.get("stanzaID") or context.get("stanza_id") or context.get("StanzaId") or context.get("StanzaID") or context.get("quotedMessageID") or context.get("quotedMessageId") or context.get("QuotedMessageID") or context.get("QuotedMessageId") or _pluck(context, "quotedMessageKey", "id") or _pluck(context, "quoted_message_key", "id") or _pluck(context, "quotedMessage", "key", "id") or _pluck(context, "quoted_message", "key", "id") ), "participant": _clean( context.get("participant") or context.get("remoteJid") or context.get("chat") or context.get("Participant") or context.get("RemoteJid") or context.get("RemoteJID") or context.get("Chat") ), } ) return { "candidate_count": len(rows), "candidates": rows[:20], } def extract_reply_ref(service: str, raw_payload: dict[str, Any]) -> dict[str, str]: svc = _clean(service).lower() payload = _as_dict(raw_payload) if svc == "xmpp": reply_id = _clean( payload.get("reply_source_message_id") or payload.get("reply_id") ) reply_chat = _clean( payload.get("reply_source_chat_id") or payload.get("reply_chat_id") ) if reply_id: return { "reply_source_message_id": reply_id, "reply_source_service": "xmpp", "reply_source_chat_id": reply_chat, } return {} if svc == "signal": return _extract_signal_reply(payload) if svc == "whatsapp": return _extract_whatsapp_reply(payload) if svc == "web": reply_id = _clean(payload.get("reply_to_message_id")) if reply_id: return { "reply_source_message_id": reply_id, "reply_source_service": "web", "reply_source_chat_id": _clean(payload.get("reply_source_chat_id")), } return {} def extract_origin_tag(raw_payload: dict[str, Any] | None) -> str: return _find_origin_tag(_as_dict(raw_payload)) async def resolve_reply_target( user, session, reply_ref: dict[str, str] ) -> Message | None: if not reply_ref or session is None: return None reply_source_message_id = _clean(reply_ref.get("reply_source_message_id")) if not reply_source_message_id: return None # Direct local UUID fallback (web compose references local Message IDs). if re.fullmatch( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", reply_source_message_id, ): direct = await sync_to_async( lambda: Message.objects.filter( user=user, session=session, id=reply_source_message_id, ).first() )() if direct is not None: return direct source_service = _clean(reply_ref.get("reply_source_service")) by_source = await sync_to_async( lambda: Message.objects.filter( user=user, session=session, source_service=source_service or None, source_message_id=reply_source_message_id, ) .order_by("-ts") .first() )() if by_source is not None: return by_source # Bridge ref fallback: resolve replies against bridge mappings persisted in # message receipt payloads. identifier = getattr(session, "identifier", None) if identifier is not None: service_candidates = [] if source_service: service_candidates.append(source_service) # XMPP replies can target bridged messages from any external service. if source_service == "xmpp": service_candidates.extend(["signal", "whatsapp", "instagram"]) for candidate in service_candidates: bridge = await history.resolve_bridge_ref( user=user, identifier=identifier, source_service=candidate, xmpp_message_id=reply_source_message_id, upstream_message_id=reply_source_message_id, ) local_message_id = _clean((bridge or {}).get("local_message_id")) if not local_message_id: continue bridged = await sync_to_async( lambda: Message.objects.filter( user=user, session=session, id=local_message_id, ).first() )() if bridged is not None: return bridged fallback = await sync_to_async( lambda: Message.objects.filter( user=user, session=session, reply_source_message_id=reply_source_message_id, ) .order_by("-ts") .first() )() return fallback def apply_sync_origin(message_meta: dict | None, origin_tag: str) -> dict: payload = dict(message_meta or {}) tag = _clean(origin_tag) if not tag: return payload payload["origin_tag"] = tag return payload def is_mirrored_origin(message_meta: dict | None) -> bool: payload = dict(message_meta or {}) return bool(_clean(payload.get("origin_tag")))