Implement reactions and image sync
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -695,6 +696,7 @@ class WhatsAppClient(ClientBase):
|
||||
recipient = str(payload.get("recipient") or "").strip()
|
||||
text = payload.get("text")
|
||||
attachments = payload.get("attachments") or []
|
||||
metadata = dict(payload.get("metadata") or {})
|
||||
send_timeout_s = 18.0
|
||||
try:
|
||||
# Include command_id so send_message_raw can observe cancel requests
|
||||
@@ -704,6 +706,7 @@ class WhatsAppClient(ClientBase):
|
||||
text=text,
|
||||
attachments=attachments,
|
||||
command_id=command_id,
|
||||
metadata=metadata,
|
||||
),
|
||||
timeout=send_timeout_s,
|
||||
)
|
||||
@@ -775,6 +778,41 @@ class WhatsAppClient(ClientBase):
|
||||
)
|
||||
return
|
||||
|
||||
if action == "send_reaction":
|
||||
recipient = str(payload.get("recipient") or "").strip()
|
||||
emoji = str(payload.get("emoji") or "")
|
||||
target_message_id = str(payload.get("target_message_id") or "").strip()
|
||||
target_timestamp = int(payload.get("target_timestamp") or 0)
|
||||
remove = bool(payload.get("remove"))
|
||||
try:
|
||||
ok = await self.send_reaction(
|
||||
recipient=recipient,
|
||||
emoji=emoji,
|
||||
target_message_id=target_message_id,
|
||||
target_timestamp=target_timestamp,
|
||||
remove=remove,
|
||||
)
|
||||
transport.set_runtime_command_result(
|
||||
self.service,
|
||||
command_id,
|
||||
{
|
||||
"ok": bool(ok),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"error": "" if ok else "reaction_send_failed",
|
||||
},
|
||||
)
|
||||
return
|
||||
except Exception as exc:
|
||||
transport.set_runtime_command_result(
|
||||
self.service,
|
||||
command_id,
|
||||
{
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if action == "force_history_sync":
|
||||
target_identifier = str(payload.get("identifier") or "").strip()
|
||||
try:
|
||||
@@ -2066,6 +2104,50 @@ class WhatsAppClient(ClientBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _infer_media_content_type(self, message_obj):
|
||||
if self._pluck(message_obj, "imageMessage") or self._pluck(
|
||||
message_obj, "image_message"
|
||||
):
|
||||
return "image/jpeg"
|
||||
if self._pluck(message_obj, "videoMessage") or self._pluck(
|
||||
message_obj, "video_message"
|
||||
):
|
||||
return "video/mp4"
|
||||
if self._pluck(message_obj, "audioMessage") or self._pluck(
|
||||
message_obj, "audio_message"
|
||||
):
|
||||
return "audio/ogg"
|
||||
if self._pluck(message_obj, "stickerMessage") or self._pluck(
|
||||
message_obj, "sticker_message"
|
||||
):
|
||||
return "image/webp"
|
||||
return "application/octet-stream"
|
||||
|
||||
def _extract_reaction_event(self, message_obj):
|
||||
node = self._pluck(message_obj, "reactionMessage") or self._pluck(
|
||||
message_obj, "reaction_message"
|
||||
)
|
||||
if not node:
|
||||
return None
|
||||
emoji = str(
|
||||
self._pluck(node, "text") or self._pluck(node, "emoji") or ""
|
||||
).strip()
|
||||
target_msg_id = str(
|
||||
self._pluck(node, "key", "id")
|
||||
or self._pluck(node, "key", "ID")
|
||||
or self._pluck(node, "targetMessageKey", "id")
|
||||
or self._pluck(node, "target_message_key", "id")
|
||||
or ""
|
||||
).strip()
|
||||
remove = bool(not emoji)
|
||||
if not target_msg_id:
|
||||
return None
|
||||
return {
|
||||
"emoji": emoji,
|
||||
"target_message_id": target_msg_id,
|
||||
"remove": remove,
|
||||
}
|
||||
|
||||
async def _download_event_media(self, event):
|
||||
if not self._client:
|
||||
return []
|
||||
@@ -2089,15 +2171,21 @@ class WhatsAppClient(ClientBase):
|
||||
filename = (
|
||||
self._pluck(msg_obj, "documentMessage", "fileName")
|
||||
or self._pluck(msg_obj, "document_message", "file_name")
|
||||
or f"wa-{int(time.time())}.bin"
|
||||
)
|
||||
content_type = (
|
||||
self._pluck(msg_obj, "documentMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "document_message", "mimetype")
|
||||
or self._pluck(msg_obj, "imageMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "image_message", "mimetype")
|
||||
or "application/octet-stream"
|
||||
or self._pluck(msg_obj, "videoMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "video_message", "mimetype")
|
||||
or self._pluck(msg_obj, "audioMessage", "mimetype")
|
||||
or self._pluck(msg_obj, "audio_message", "mimetype")
|
||||
or self._infer_media_content_type(msg_obj)
|
||||
)
|
||||
if not filename:
|
||||
ext = mimetypes.guess_extension(str(content_type or "").split(";", 1)[0].strip().lower())
|
||||
filename = f"wa-{int(time.time())}{ext or '.bin'}"
|
||||
blob_key = media_bridge.put_blob(
|
||||
service="whatsapp",
|
||||
content=bytes(payload),
|
||||
@@ -2119,7 +2207,7 @@ class WhatsAppClient(ClientBase):
|
||||
msg_obj = self._pluck(event, "message") or self._pluck(event, "Message")
|
||||
text = self._message_text(msg_obj, event)
|
||||
if not text:
|
||||
self.log.info(
|
||||
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),
|
||||
@@ -2158,6 +2246,54 @@ class WhatsAppClient(ClientBase):
|
||||
or ""
|
||||
).strip()
|
||||
ts = self._normalize_timestamp(raw_ts)
|
||||
|
||||
reaction_payload = self._extract_reaction_event(msg_obj)
|
||||
if reaction_payload:
|
||||
self.log.debug(
|
||||
"reaction-bridge whatsapp-inbound msg_id=%s target_id=%s emoji=%s remove=%s sender=%s chat=%s",
|
||||
msg_id or "-",
|
||||
str(reaction_payload.get("target_message_id") or "") or "-",
|
||||
str(reaction_payload.get("emoji") or "") or "-",
|
||||
bool(reaction_payload.get("remove")),
|
||||
sender or "-",
|
||||
chat or "-",
|
||||
)
|
||||
identifier_values = self._normalize_identifier_candidates(sender, chat)
|
||||
if not identifier_values:
|
||||
self.log.warning(
|
||||
"reaction-bridge whatsapp-identifiers-miss sender=%s chat=%s",
|
||||
sender or "-",
|
||||
chat or "-",
|
||||
)
|
||||
return
|
||||
identifiers = await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
service="whatsapp",
|
||||
identifier__in=list(identifier_values),
|
||||
)
|
||||
)
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
await self.ur.xmpp.client.apply_external_reaction(
|
||||
identifier.user,
|
||||
identifier,
|
||||
source_service="whatsapp",
|
||||
emoji=str(reaction_payload.get("emoji") or ""),
|
||||
remove=bool(reaction_payload.get("remove")),
|
||||
upstream_message_id=str(
|
||||
reaction_payload.get("target_message_id") or ""
|
||||
),
|
||||
upstream_ts=0,
|
||||
actor=(sender or chat or ""),
|
||||
payload={
|
||||
"event": "reaction",
|
||||
"message_id": msg_id,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp reaction relay to XMPP failed: %s", exc)
|
||||
return
|
||||
|
||||
self._remember_contact(
|
||||
sender or chat,
|
||||
jid=sender,
|
||||
@@ -2206,6 +2342,11 @@ class WhatsAppClient(ClientBase):
|
||||
text,
|
||||
is_outgoing_message=is_from_me,
|
||||
attachments=xmpp_attachments,
|
||||
source_ref={
|
||||
"upstream_message_id": str(msg_id or ""),
|
||||
"upstream_author": str(sender or chat or ""),
|
||||
"upstream_ts": int(ts or 0),
|
||||
},
|
||||
)
|
||||
display_text = text
|
||||
if (not display_text) and uploaded_urls:
|
||||
@@ -2440,7 +2581,12 @@ class WhatsAppClient(ClientBase):
|
||||
return None
|
||||
|
||||
async def send_message_raw(
|
||||
self, recipient, text=None, attachments=None, command_id: str | None = None
|
||||
self,
|
||||
recipient,
|
||||
text=None,
|
||||
attachments=None,
|
||||
command_id: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
):
|
||||
self._last_send_error = ""
|
||||
if not self._client:
|
||||
@@ -2500,6 +2646,46 @@ class WhatsAppClient(ClientBase):
|
||||
|
||||
sent_any = False
|
||||
sent_ts = 0
|
||||
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()
|
||||
person_identifier = None
|
||||
if xmpp_source_id:
|
||||
candidates = list(self._normalize_identifier_candidates(recipient, jid_str))
|
||||
if candidates:
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
service="whatsapp",
|
||||
identifier__in=candidates,
|
||||
)
|
||||
.select_related("user", "person")
|
||||
.first()
|
||||
)()
|
||||
|
||||
def _extract_response_message_id(response):
|
||||
return str(
|
||||
self._pluck(response, "ID")
|
||||
or self._pluck(response, "id")
|
||||
or self._pluck(response, "Info", "ID")
|
||||
or self._pluck(response, "info", "id")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def _record_bridge(response, ts_value, body_hint=""):
|
||||
if not xmpp_source_id or person_identifier is None:
|
||||
return
|
||||
transport.record_bridge_mapping(
|
||||
user_id=person_identifier.user_id,
|
||||
person_id=person_identifier.person_id,
|
||||
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_ts=int(ts_value or 0),
|
||||
text_preview=str(body_hint or metadata.get("xmpp_body") or ""),
|
||||
local_message_id=legacy_message_id,
|
||||
)
|
||||
|
||||
for attachment in attachments or []:
|
||||
payload = await self._fetch_attachment_payload(attachment)
|
||||
if not payload:
|
||||
@@ -2510,6 +2696,22 @@ class WhatsAppClient(ClientBase):
|
||||
data = payload.get("content") or b""
|
||||
filename = payload.get("filename") or "attachment.bin"
|
||||
attachment_target = jid_obj if jid_obj is not None else jid
|
||||
send_method = "document"
|
||||
if mime.startswith("image/") and hasattr(self._client, "send_image"):
|
||||
send_method = "image"
|
||||
elif mime.startswith("video/") and hasattr(self._client, "send_video"):
|
||||
send_method = "video"
|
||||
elif mime.startswith("audio/") and hasattr(self._client, "send_audio"):
|
||||
send_method = "audio"
|
||||
|
||||
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||
self.log.debug(
|
||||
"whatsapp media send prep: method=%s mime=%s filename=%s size=%s",
|
||||
send_method,
|
||||
mime,
|
||||
filename,
|
||||
len(data) if isinstance(data, (bytes, bytearray)) else 0,
|
||||
)
|
||||
|
||||
try:
|
||||
if mime.startswith("image/") and hasattr(self._client, "send_image"):
|
||||
@@ -2540,7 +2742,15 @@ class WhatsAppClient(ClientBase):
|
||||
sent_ts,
|
||||
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
||||
)
|
||||
_record_bridge(response, sent_ts, body_hint=filename)
|
||||
sent_any = True
|
||||
if getattr(settings, "WHATSAPP_DEBUG", False):
|
||||
self.log.debug(
|
||||
"whatsapp media send ok: method=%s filename=%s ts=%s",
|
||||
send_method,
|
||||
filename,
|
||||
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp attachment send failed: %s", exc)
|
||||
|
||||
@@ -2661,6 +2871,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 ""))
|
||||
|
||||
if not sent_any:
|
||||
self._last_send_error = "no_payload_sent"
|
||||
@@ -2730,6 +2941,72 @@ class WhatsAppClient(ClientBase):
|
||||
pass
|
||||
return False
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
recipient,
|
||||
*,
|
||||
emoji,
|
||||
target_message_id="",
|
||||
target_timestamp=0,
|
||||
remove=False,
|
||||
):
|
||||
if not self._client:
|
||||
return False
|
||||
jid = self._to_jid(recipient)
|
||||
if not jid:
|
||||
return False
|
||||
target_id = str(target_message_id or "").strip()
|
||||
if not target_id:
|
||||
return False
|
||||
|
||||
reaction_emoji = "" if remove else str(emoji or "").strip()
|
||||
candidate_names = (
|
||||
"send_reaction",
|
||||
"react",
|
||||
"send_message_reaction",
|
||||
"reaction",
|
||||
)
|
||||
self.log.debug(
|
||||
"reaction-bridge whatsapp-send start recipient=%s target_id=%s emoji=%s remove=%s",
|
||||
recipient,
|
||||
target_id,
|
||||
reaction_emoji or "-",
|
||||
bool(remove),
|
||||
)
|
||||
for method_name in candidate_names:
|
||||
method = getattr(self._client, method_name, None)
|
||||
if method is None:
|
||||
continue
|
||||
attempts = [
|
||||
(jid, target_id, reaction_emoji),
|
||||
(jid, target_id, reaction_emoji, bool(remove)),
|
||||
(jid, reaction_emoji, target_id),
|
||||
]
|
||||
for args in attempts:
|
||||
try:
|
||||
response = await self._call_client_method(method, *args, timeout=9.0)
|
||||
if response is not None:
|
||||
self.log.debug(
|
||||
"reaction-bridge whatsapp-send ok method=%s args_len=%s",
|
||||
method_name,
|
||||
len(args),
|
||||
)
|
||||
return True
|
||||
except Exception as exc:
|
||||
self.log.debug(
|
||||
"reaction-bridge whatsapp-send miss method=%s args_len=%s error=%s",
|
||||
method_name,
|
||||
len(args),
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
self.log.warning(
|
||||
"reaction-bridge whatsapp-send failed recipient=%s target_id=%s",
|
||||
recipient,
|
||||
target_id,
|
||||
)
|
||||
return False
|
||||
|
||||
async def fetch_attachment(self, attachment_ref):
|
||||
blob_key = (attachment_ref or {}).get("blob_key")
|
||||
if blob_key:
|
||||
|
||||
Reference in New Issue
Block a user