Implement business plans

This commit is contained in:
2026-03-02 00:00:53 +00:00
parent d22924f6aa
commit b3e183eb0a
26 changed files with 4109 additions and 39 deletions

View File

@@ -1,5 +1,6 @@
import asyncio
import inspect
import json
import logging
import mimetypes
import os
@@ -14,9 +15,14 @@ from django.conf import settings
from django.core.cache import cache
from core.clients import ClientBase, transport
from core.messaging import history, media_bridge
from core.messaging import history, media_bridge, reply_sync
from core.models import Message, PersonIdentifier, PlatformChatLink
try:
from google.protobuf.json_format import MessageToDict
except Exception: # pragma: no cover
MessageToDict = None
class WhatsAppClient(ClientBase):
"""
@@ -45,6 +51,9 @@ class WhatsAppClient(ClientBase):
self._qr_handler_supported = False
self._event_hook_callable = False
self._last_send_error = ""
self.reply_debug_chat = str(
getattr(settings, "WHATSAPP_REPLY_DEBUG_CHAT", "120363402761690215")
).strip()
self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -1464,6 +1473,72 @@ class WhatsAppClient(ClientBase):
return sorted(str(key) for key in vars(obj).keys())
return []
def _proto_to_dict(self, obj):
if obj is None:
return {}
if isinstance(obj, dict):
return obj
# Neonize emits protobuf objects for inbound events. Convert them to a
# plain dict so nested contextInfo reply fields are addressable.
if MessageToDict is not None and hasattr(obj, "DESCRIPTOR"):
try:
return MessageToDict(
obj,
preserving_proto_field_name=True,
use_integers_for_enums=True,
)
except Exception:
pass
return {}
def _chat_matches_reply_debug(self, chat: str) -> bool:
target = str(self.reply_debug_chat or "").strip()
value = str(chat or "").strip()
if not target or not value:
return False
value_local = value.split("@", 1)[0]
return value == target or value_local == target
def _extract_reply_hints(self, obj, max_depth: int = 6):
hints = []
def walk(value, path="", depth=0):
if depth > max_depth or value is None:
return
if isinstance(value, dict):
for key, nested in value.items():
key_str = str(key)
next_path = f"{path}.{key_str}" if path else key_str
lowered = key_str.lower()
if any(
token in lowered
for token in ("stanza", "quoted", "reply", "context")
):
if isinstance(nested, (str, int, float, bool)):
hints.append(
{
"path": next_path,
"value": str(nested)[:180],
}
)
walk(nested, next_path, depth + 1)
return
if isinstance(value, list):
for idx, nested in enumerate(value):
walk(nested, f"{path}[{idx}]", depth + 1)
walk(obj, "", 0)
# Deduplicate by path/value for compact diagnostics.
unique = []
seen = set()
for row in hints:
key = (str(row.get("path") or ""), str(row.get("value") or ""))
if key in seen:
continue
seen.add(key)
unique.append(row)
return unique[:40]
def _normalize_timestamp(self, raw_value):
if raw_value is None:
return int(time.time() * 1000)
@@ -2361,19 +2436,22 @@ class WhatsAppClient(ClientBase):
]
async def _handle_message_event(self, event):
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
text = self._message_text(msg_obj, event)
event_obj = self._proto_to_dict(event) or event
msg_obj = self._pluck(event_obj, "message") or self._pluck(event_obj, "Message")
text = self._message_text(msg_obj, event_obj)
if not text:
self.log.debug(
"whatsapp empty-text event shape: msg_keys=%s event_keys=%s type=%s",
self._shape_keys(msg_obj),
self._shape_keys(event),
self._shape_keys(event_obj),
str(type(event).__name__),
)
source = (
self._pluck(event, "Info", "MessageSource")
or self._pluck(event, "info", "message_source")
or self._pluck(event, "info", "messageSource")
self._pluck(event_obj, "Info", "MessageSource")
or self._pluck(event_obj, "info", "message_source")
or self._pluck(event_obj, "info", "messageSource")
or self._pluck(event_obj, "info", "message_source")
or self._pluck(event_obj, "info", "messageSource")
)
is_from_me = bool(
self._pluck(source, "IsFromMe") or self._pluck(source, "isFromMe")
@@ -2389,17 +2467,17 @@ class WhatsAppClient(ClientBase):
self._pluck(source, "Chat") or self._pluck(source, "chat")
)
raw_ts = (
self._pluck(event, "Info", "Timestamp")
or self._pluck(event, "info", "timestamp")
or self._pluck(event, "info", "message_timestamp")
or self._pluck(event, "Timestamp")
or self._pluck(event, "timestamp")
self._pluck(event_obj, "Info", "Timestamp")
or self._pluck(event_obj, "info", "timestamp")
or self._pluck(event_obj, "info", "message_timestamp")
or self._pluck(event_obj, "Timestamp")
or self._pluck(event_obj, "timestamp")
)
msg_id = str(
self._pluck(event, "Info", "ID")
or self._pluck(event, "info", "id")
or self._pluck(event, "ID")
or self._pluck(event, "id")
self._pluck(event_obj, "Info", "ID")
or self._pluck(event_obj, "info", "id")
or self._pluck(event_obj, "ID")
or self._pluck(event_obj, "id")
or ""
).strip()
ts = self._normalize_timestamp(raw_ts)
@@ -2529,12 +2607,196 @@ class WhatsAppClient(ClientBase):
)()
if duplicate_exists:
continue
await history.store_message(
reply_ref = reply_sync.extract_reply_ref(
self.service,
{
"contextInfo": self._pluck(msg_obj, "contextInfo")
or self._pluck(msg_obj, "ContextInfo")
or self._pluck(msg_obj, "extendedTextMessage", "contextInfo")
or self._pluck(msg_obj, "ExtendedTextMessage", "ContextInfo")
or self._pluck(msg_obj, "imageMessage", "contextInfo")
or self._pluck(msg_obj, "ImageMessage", "ContextInfo")
or self._pluck(msg_obj, "videoMessage", "contextInfo")
or self._pluck(msg_obj, "VideoMessage", "ContextInfo")
or self._pluck(
msg_obj,
"ephemeralMessage",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"EphemeralMessage",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessage",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessage",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessageV2",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessageV2",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or self._pluck(
msg_obj,
"viewOnceMessageV2Extension",
"message",
"extendedTextMessage",
"contextInfo",
)
or self._pluck(
msg_obj,
"ViewOnceMessageV2Extension",
"Message",
"ExtendedTextMessage",
"ContextInfo",
)
or {},
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
or self._pluck(msg_obj, "MessageContextInfo")
or {},
"message": {
"extendedTextMessage": self._pluck(msg_obj, "extendedTextMessage")
or self._pluck(msg_obj, "ExtendedTextMessage")
or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
"ImageMessage": self._pluck(msg_obj, "ImageMessage") or {},
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage")
or {},
"DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {},
"EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {},
"ViewOnceMessage": self._pluck(msg_obj, "ViewOnceMessage")
or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
or {},
"ViewOnceMessageV2": self._pluck(msg_obj, "ViewOnceMessageV2")
or {},
"viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension"
)
or {},
"ViewOnceMessageV2Extension": self._pluck(
msg_obj, "ViewOnceMessageV2Extension"
)
or {},
},
},
)
reply_debug = {}
if self._chat_matches_reply_debug(chat):
reply_debug = reply_sync.extract_whatsapp_reply_debug(
{
"contextInfo": self._pluck(msg_obj, "contextInfo") or {},
"messageContextInfo": self._pluck(msg_obj, "messageContextInfo")
or {},
"message": {
"extendedTextMessage": self._pluck(
msg_obj, "extendedTextMessage"
)
or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage")
or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
or {},
"viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension"
)
or {},
},
}
)
self.log.warning(
"wa-reply-debug chat=%s msg_id=%s reply_ref=%s debug=%s",
str(chat or ""),
str(msg_id or ""),
json.dumps(reply_ref, ensure_ascii=True),
json.dumps(reply_debug, ensure_ascii=True),
)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
session,
reply_ref,
)
message_meta = reply_sync.apply_sync_origin(
{},
reply_sync.extract_origin_tag(payload),
)
if self._chat_matches_reply_debug(chat):
info_obj = self._proto_to_dict(self._pluck(event_obj, "Info")) or self._pluck(
event_obj, "Info"
)
raw_obj = self._proto_to_dict(self._pluck(event_obj, "Raw")) or self._pluck(
event_obj, "Raw"
)
message_meta["wa_reply_debug"] = {
"reply_ref": reply_ref,
"reply_target_id": str(getattr(reply_target, "id", "") or ""),
"msg_id": str(msg_id or ""),
"chat": str(chat or ""),
"sender": str(sender or ""),
"msg_obj_keys": self._shape_keys(msg_obj),
"event_keys": self._shape_keys(event_obj),
"info_keys": self._shape_keys(info_obj),
"raw_keys": self._shape_keys(raw_obj),
"event_type": str(type(event).__name__),
"reply_hints_event": self._extract_reply_hints(event_obj),
"reply_hints_message": self._extract_reply_hints(msg_obj),
"reply_hints_info": self._extract_reply_hints(info_obj),
"reply_hints_raw": self._extract_reply_hints(raw_obj),
"debug": reply_debug,
}
local_message = await history.store_message(
session=session,
sender=str(sender or chat or ""),
text=display_text,
ts=ts,
outgoing=is_from_me,
source_service=self.service,
source_message_id=str(msg_id or ""),
source_chat_id=str(chat or sender or ""),
reply_to=reply_target,
reply_source_service=str(reply_ref.get("reply_source_service") or ""),
reply_source_message_id=str(
reply_ref.get("reply_source_message_id") or ""
),
message_meta=message_meta,
)
await self.ur.message_received(
self.service,
@@ -2542,6 +2804,7 @@ class WhatsAppClient(ClientBase):
text=display_text,
ts=ts,
payload=payload,
local_message=local_message,
)
async def _handle_receipt_event(self, event):
@@ -2679,6 +2942,11 @@ class WhatsAppClient(ClientBase):
return ""
if "@" in raw:
return raw
# Group chats often arrive as bare numeric ids in compose/runtime
# payloads; prefer known group mappings before defaulting to person JIDs.
group_jid = self._resolve_group_jid(raw)
if group_jid:
return group_jid
digits = re.sub(r"[^0-9]", "", raw)
if digits:
# Prefer direct JID formatting for phone numbers; Neonize build_jid
@@ -2691,6 +2959,66 @@ class WhatsAppClient(ClientBase):
pass
return raw
def _resolve_group_jid(self, value: str) -> str:
local = str(value or "").strip().split("@", 1)[0].strip()
if not local:
return ""
# Runtime state is the cheapest source of truth for currently joined groups.
state = transport.get_runtime_state(self.service) or {}
for row in list(state.get("groups") or []):
if not isinstance(row, dict):
continue
candidates = (
row.get("identifier"),
row.get("chat_identifier"),
row.get("chat"),
row.get("jid"),
row.get("chat_jid"),
)
matched = False
for candidate in candidates:
candidate_local = str(self._jid_to_identifier(candidate) or "").split(
"@", 1
)[0].strip()
if candidate_local and candidate_local == local:
matched = True
break
if not matched:
continue
jid = str(
self._jid_to_identifier(row.get("jid") or row.get("chat_jid") or "")
).strip()
if jid and "@g.us" in jid:
return jid
return f"{local}@g.us"
# DB fallback for compose pages that resolved from PlatformChatLink.
try:
link = (
PlatformChatLink.objects.filter(
service="whatsapp",
chat_identifier=local,
is_group=True,
)
.order_by("-updated_at", "-id")
.first()
)
except Exception:
link = None
if link is not None:
jid = str(self._jid_to_identifier(link.chat_jid or "")).strip()
if jid and "@g.us" in jid:
return jid
return f"{local}@g.us"
# WhatsApp group ids are numeric and usually very long (commonly start
# with 120...). Treat those as groups when no explicit mapping exists.
digits = re.sub(r"[^0-9]", "", local)
if digits and digits == local and len(digits) >= 15 and digits.startswith("120"):
return f"{digits}@g.us"
return ""
def _blob_key_to_compose_url(self, blob_key):
key = str(blob_key or "").strip()
if not key:
@@ -2806,8 +3134,31 @@ class WhatsAppClient(ClientBase):
metadata = dict(metadata or {})
xmpp_source_id = str(metadata.get("xmpp_source_id") or "").strip()
legacy_message_id = str(metadata.get("legacy_message_id") or "").strip()
reply_to_upstream_message_id = str(
metadata.get("reply_to_upstream_message_id") or ""
).strip()
reply_to_participant = str(metadata.get("reply_to_participant") or "").strip()
reply_to_remote_jid = str(metadata.get("reply_to_remote_jid") or "").strip()
person_identifier = None
if xmpp_source_id:
if legacy_message_id:
person_identifier = await sync_to_async(
lambda: (
Message.objects.filter(id=legacy_message_id)
.select_related("session__identifier__user", "session__identifier__person")
.first()
)
)()
if person_identifier is not None:
person_identifier = getattr(
getattr(person_identifier, "session", None), "identifier", None
)
if (
person_identifier is not None
and str(getattr(person_identifier, "service", "") or "").strip().lower()
!= "whatsapp"
):
person_identifier = None
if person_identifier is None and (xmpp_source_id or legacy_message_id):
candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
if candidates:
person_identifier = await sync_to_async(
@@ -2828,8 +3179,25 @@ class WhatsAppClient(ClientBase):
or ""
).strip()
def _record_bridge(response, ts_value, body_hint=""):
if not xmpp_source_id or person_identifier is None:
async def _record_bridge(response, ts_value, body_hint=""):
if person_identifier is None:
return
upstream_message_id = _extract_response_message_id(response)
if legacy_message_id:
try:
await history.save_bridge_ref(
person_identifier.user,
person_identifier,
source_service="whatsapp",
local_message_id=legacy_message_id,
local_ts=int(ts_value or int(time.time() * 1000)),
upstream_message_id=upstream_message_id,
upstream_author=str(recipient or ""),
upstream_ts=int(ts_value or 0),
)
except Exception:
pass
if not xmpp_source_id:
return
transport.record_bridge_mapping(
user_id=person_identifier.user_id,
@@ -2837,7 +3205,7 @@ class WhatsAppClient(ClientBase):
service="whatsapp",
xmpp_message_id=xmpp_source_id,
xmpp_ts=int(metadata.get("xmpp_source_ts") or 0),
upstream_message_id=_extract_response_message_id(response),
upstream_message_id=upstream_message_id,
upstream_ts=int(ts_value or 0),
text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
local_message_id=legacy_message_id,
@@ -2899,7 +3267,7 @@ class WhatsAppClient(ClientBase):
sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
)
_record_bridge(response, sent_ts, body_hint=filename)
await _record_bridge(response, sent_ts, body_hint=filename)
sent_any = True
if getattr(settings, "WHATSAPP_DEBUG", False):
self.log.debug(
@@ -2916,6 +3284,35 @@ class WhatsAppClient(ClientBase):
if text:
response = None
last_error = None
quoted_text_message = text
if reply_to_upstream_message_id:
try:
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
ContextInfo,
ExtendedTextMessage,
Message as WAProtoMessage,
)
context = ContextInfo(
stanzaID=reply_to_upstream_message_id,
)
participant_jid = self._to_jid(reply_to_participant)
remote_jid = self._to_jid(reply_to_remote_jid) or jid_str
if participant_jid:
context.participant = participant_jid
if remote_jid:
context.remoteJID = remote_jid
quoted_text_message = WAProtoMessage(
extendedTextMessage=ExtendedTextMessage(
text=str(text or ""),
contextInfo=context,
)
)
except Exception as exc:
self.log.warning(
"whatsapp quoted-reply payload build failed: %s", exc
)
quoted_text_message = text
# Prepare cancel key (if caller provided command_id)
cancel_key = None
try:
@@ -2945,7 +3342,7 @@ class WhatsAppClient(ClientBase):
response = await self._call_client_method(
getattr(self._client, "send_message", None),
send_target,
text,
quoted_text_message,
timeout=9.0,
)
sent_any = True
@@ -3030,7 +3427,7 @@ class WhatsAppClient(ClientBase):
sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
)
_record_bridge(response, sent_ts, body_hint=str(text or ""))
await _record_bridge(response, sent_ts, body_hint=str(text or ""))
if not sent_any:
self._last_send_error = "no_payload_sent"