Implement reactions and image sync

This commit is contained in:
2026-02-17 21:23:03 +00:00
parent cccdb7b72a
commit fb46274bf3
14 changed files with 2011 additions and 202 deletions

View File

@@ -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: