diff --git a/core/clients/transport.py b/core/clients/transport.py index ea4c2ab..e8e439b 100644 --- a/core/clients/transport.py +++ b/core/clients/transport.py @@ -238,7 +238,23 @@ async def send_message_raw(service: str, recipient: str, text=None, attachments= if service_key == "signal": return await signalapi.send_message_raw(recipient, text, attachments or []) - if service_key in {"whatsapp", "instagram"}: + if service_key == "whatsapp": + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "send_message_raw"): + try: + runtime_result = await runtime_client.send_message_raw( + recipient, + text=text, + attachments=attachments or [], + ) + if runtime_result is not False and runtime_result is not None: + return runtime_result + except Exception as exc: + log.warning("%s runtime send failed: %s", service_key, exc) + log.warning("whatsapp send skipped: runtime is unavailable or not paired") + return False + + if service_key == "instagram": runtime_client = get_runtime_client(service_key) if runtime_client and hasattr(runtime_client, "send_message_raw"): try: @@ -269,7 +285,18 @@ async def start_typing(service: str, recipient: str): await signalapi.start_typing(recipient) return True - if service_key in {"whatsapp", "instagram"}: + if service_key == "whatsapp": + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "start_typing"): + try: + result = await runtime_client.start_typing(recipient) + if result: + return True + except Exception as exc: + log.warning("%s runtime start_typing failed: %s", service_key, exc) + return False + + if service_key == "instagram": runtime_client = get_runtime_client(service_key) if runtime_client and hasattr(runtime_client, "start_typing"): try: @@ -288,7 +315,18 @@ async def stop_typing(service: str, recipient: str): await signalapi.stop_typing(recipient) return True - if service_key in {"whatsapp", "instagram"}: + if service_key == "whatsapp": + runtime_client = get_runtime_client(service_key) + if runtime_client and hasattr(runtime_client, "stop_typing"): + try: + result = await runtime_client.stop_typing(recipient) + if result: + return True + except Exception as exc: + log.warning("%s runtime stop_typing failed: %s", service_key, exc) + return False + + if service_key == "instagram": runtime_client = get_runtime_client(service_key) if runtime_client and hasattr(runtime_client, "stop_typing"): try: diff --git a/core/clients/whatsapp.py b/core/clients/whatsapp.py index 179500a..e003ba6 100644 --- a/core/clients/whatsapp.py +++ b/core/clients/whatsapp.py @@ -30,6 +30,8 @@ class WhatsAppClient(ClientBase): self._connected = False self._last_qr_payload = "" self._accounts = [] + self._chat_presence = None + self._chat_presence_media = None self.enabled = bool( str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() @@ -71,6 +73,11 @@ class WhatsAppClient(ClientBase): try: from neonize.aioze.client import NewAClient from neonize.aioze import events as wa_events + try: + from neonize.utils.enum import ChatPresence, ChatPresenceMedia + except Exception: + ChatPresence = None + ChatPresenceMedia = None try: from neonize.utils import build_jid as wa_build_jid except Exception: @@ -85,6 +92,8 @@ class WhatsAppClient(ClientBase): return self._build_jid = wa_build_jid + self._chat_presence = ChatPresence + self._chat_presence_media = ChatPresenceMedia self._client = self._build_client(NewAClient) if self._client is None: self._publish_state( @@ -113,6 +122,59 @@ class WhatsAppClient(ClientBase): while not self._stopping: await asyncio.sleep(1) + def _register_event(self, event_cls, callback): + if event_cls is None: + return + if self._client is None: + return + event_hook = getattr(self._client, "event", None) + if not callable(event_hook): + return + try: + decorator = event_hook(event_cls) + decorator(callback) + except Exception as exc: + self.log.warning( + "whatsapp event registration failed (%s): %s", + getattr(event_cls, "__name__", str(event_cls)), + exc, + ) + + def _register_qr_handler(self): + if self._client is None or not hasattr(self._client, "qr"): + return + + async def on_qr(client, raw_payload): + qr_payload = self._decode_qr_payload(raw_payload) + if not qr_payload: + return + self._last_qr_payload = qr_payload + self._publish_state( + connected=False, + pair_qr=qr_payload, + warning="Scan QR in WhatsApp Linked Devices.", + ) + + try: + self._client.qr(on_qr) + except Exception as exc: + self.log.warning("whatsapp qr handler registration failed: %s", exc) + + def _decode_qr_payload(self, raw_payload): + if raw_payload is None: + return "" + if isinstance(raw_payload, memoryview): + raw_payload = raw_payload.tobytes() + if isinstance(raw_payload, bytes): + return raw_payload.decode("utf-8", errors="ignore").strip() + if isinstance(raw_payload, (list, tuple)): + for item in raw_payload: + candidate = self._decode_qr_payload(item) + if candidate: + return candidate + return "" + return str(raw_payload).strip() + def _build_client(self, cls): candidates = [] if self.database_url: @@ -137,8 +199,12 @@ class WhatsAppClient(ClientBase): connected_ev = getattr(wa_events, "ConnectedEv", None) message_ev = getattr(wa_events, "MessageEv", None) receipt_ev = getattr(wa_events, "ReceiptEv", None) + chat_presence_ev = getattr(wa_events, "ChatPresenceEv", None) presence_ev = getattr(wa_events, "PresenceEv", None) pair_ev = getattr(wa_events, "PairStatusEv", None) + qr_ev = getattr(wa_events, "QREv", None) + + self._register_qr_handler() if connected_ev is not None: @@ -149,30 +215,38 @@ class WhatsAppClient(ClientBase): connected=True, warning="", accounts=[account] if account else [self.client_name], + pair_qr="", ) - self._client.event(on_connected) + self._register_event(connected_ev, on_connected) if message_ev is not None: async def on_message(client, event: message_ev): await self._handle_message_event(event) - self._client.event(on_message) + self._register_event(message_ev, on_message) if receipt_ev is not None: async def on_receipt(client, event: receipt_ev): await self._handle_receipt_event(event) - self._client.event(on_receipt) + self._register_event(receipt_ev, on_receipt) + + if chat_presence_ev is not None: + + async def on_chat_presence(client, event: chat_presence_ev): + await self._handle_chat_presence_event(event) + + self._register_event(chat_presence_ev, on_chat_presence) if presence_ev is not None: async def on_presence(client, event: presence_ev): await self._handle_presence_event(event) - self._client.event(on_presence) + self._register_event(presence_ev, on_presence) if pair_ev is not None: @@ -182,10 +256,41 @@ class WhatsAppClient(ClientBase): self._last_qr_payload = qr_payload self._publish_state( pair_qr=qr_payload, - warning="Scan QR to pair WhatsApp account.", + warning="Scan QR in WhatsApp Linked Devices.", + ) + status_raw = self._pluck(event, "Status") + status_text = str(status_raw or "").strip().lower() + if status_text in {"2", "success"}: + account = await self._resolve_account_identifier() + self._connected = True + self._publish_state( + connected=True, + warning="", + accounts=[account] if account else [self.client_name], + pair_qr="", + ) + elif status_text in {"1", "error"}: + error_text = str(self._pluck(event, "Error") or "").strip() + self._publish_state( + warning=error_text or "WhatsApp pairing failed. Retry scan.", ) - self._client.event(on_pair_status) + self._register_event(pair_ev, on_pair_status) + + if qr_ev is not None: + + async def on_qr_event(client, event: qr_ev): + qr_payload = self._extract_pair_qr(event) + if not qr_payload: + return + self._last_qr_payload = qr_payload + self._publish_state( + connected=False, + pair_qr=qr_payload, + warning="Scan QR in WhatsApp Linked Devices.", + ) + + self._register_event(qr_ev, on_qr_event) async def _maybe_await(self, value): if asyncio.iscoroutine(value): @@ -202,6 +307,13 @@ class WhatsAppClient(ClientBase): except Exception: return self.client_name # Support both dict-like and object-like payloads. + for path in ( + ("JID",), + ("jid",), + ): + value = self._jid_to_identifier(self._pluck(me, *path)) + if value: + return value for path in ( ("JID", "User"), ("jid",), @@ -242,7 +354,7 @@ class WhatsAppClient(ClientBase): def _normalize_identifier_candidates(self, *values): out = set() for value in values: - raw = str(value or "").strip() + raw = self._jid_to_identifier(value) if not raw: continue out.add(raw) @@ -255,6 +367,19 @@ class WhatsAppClient(ClientBase): out.add(f"+{digits}") return out + def _jid_to_identifier(self, value): + raw = str(value or "").strip() + if not raw: + return "" + if not isinstance(value, str): + user = self._pluck(value, "User") or self._pluck(value, "user") + server = self._pluck(value, "Server") or self._pluck(value, "server") + if user and server: + return f"{user}@{server}" + if user: + return str(user) + return raw + def _is_media_message(self, message_obj): media_fields = ( "imageMessage", @@ -277,7 +402,7 @@ class WhatsAppClient(ClientBase): async def _download_event_media(self, event): if not self._client: return [] - msg_obj = self._pluck(event, "message") + msg_obj = self._pluck(event, "message") or self._pluck(event, "Message") if msg_obj is None or not self._is_media_message(msg_obj): return [] if not hasattr(self._client, "download_any"): @@ -324,27 +449,42 @@ class WhatsAppClient(ClientBase): ] async def _handle_message_event(self, event): - msg_obj = self._pluck(event, "message") + msg_obj = self._pluck(event, "message") or self._pluck(event, "Message") text = ( self._pluck(msg_obj, "conversation") + or self._pluck(msg_obj, "Conversation") or self._pluck(msg_obj, "extendedTextMessage", "text") + or self._pluck(msg_obj, "ExtendedTextMessage", "Text") or self._pluck(msg_obj, "extended_text_message", "text") or "" ) - - sender = ( - self._pluck(event, "info", "message_source", "sender") - or self._pluck(event, "info", "messageSource", "sender") - or "" + source = ( + self._pluck(event, "Info", "MessageSource") + or self._pluck(event, "info", "message_source") + or self._pluck(event, "info", "messageSource") ) - chat = ( - self._pluck(event, "info", "message_source", "chat") - or self._pluck(event, "info", "messageSource", "chat") - or "" + is_from_me = bool( + self._pluck(source, "IsFromMe") + or self._pluck(source, "isFromMe") + ) + if is_from_me: + return + + sender = self._jid_to_identifier( + self._pluck(source, "Sender") + or self._pluck(source, "sender") + or self._pluck(source, "SenderAlt") + or self._pluck(source, "senderAlt") + ) + chat = self._jid_to_identifier( + self._pluck(source, "Chat") + or self._pluck(source, "chat") ) raw_ts = ( - self._pluck(event, "info", "timestamp") + 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") ) ts = self._normalize_timestamp(raw_ts) @@ -401,26 +541,33 @@ class WhatsAppClient(ClientBase): ) async def _handle_receipt_event(self, event): - sender = ( - self._pluck(event, "info", "message_source", "sender") - or self._pluck(event, "info", "messageSource", "sender") - or "" + source = ( + self._pluck(event, "MessageSource") + or self._pluck(event, "info", "message_source") + or self._pluck(event, "info", "messageSource") ) - chat = ( - self._pluck(event, "info", "message_source", "chat") - or self._pluck(event, "info", "messageSource", "chat") - or "" + sender = self._jid_to_identifier( + self._pluck(source, "Sender") + or self._pluck(source, "sender") + ) + chat = self._jid_to_identifier( + self._pluck(source, "Chat") or self._pluck(source, "chat") ) timestamps = [] - raw_ids = self._pluck(event, "message_ids") or [] - if isinstance(raw_ids, list): + raw_ids = self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or [] + if isinstance(raw_ids, (list, tuple, set)): for item in raw_ids: try: value = int(item) timestamps.append(value * 1000 if value < 10**12 else value) except Exception: continue - read_ts = self._normalize_timestamp(self._pluck(event, "timestamp") or int(time.time() * 1000)) + read_ts = self._normalize_timestamp( + self._pluck(event, "Timestamp") + or self._pluck(event, "timestamp") + or int(time.time() * 1000) + ) + receipt_type = str(self._pluck(event, "Type") or "").strip() for candidate in self._normalize_identifier_candidates(sender, chat): await self.ur.message_read( @@ -429,37 +576,68 @@ class WhatsAppClient(ClientBase): message_timestamps=timestamps, read_ts=read_ts, read_by=sender or chat, - payload={"event": "receipt", "sender": str(sender), "chat": str(chat)}, + payload={ + "event": "receipt", + "type": receipt_type, + "sender": str(sender), + "chat": str(chat), + }, ) - async def _handle_presence_event(self, event): - sender = ( - self._pluck(event, "message_source", "sender") - or self._pluck(event, "info", "message_source", "sender") - or "" + async def _handle_chat_presence_event(self, event): + source = ( + self._pluck(event, "MessageSource") + or self._pluck(event, "message_source") + or {} ) - chat = ( - self._pluck(event, "message_source", "chat") - or self._pluck(event, "info", "message_source", "chat") - or "" + sender = self._jid_to_identifier( + self._pluck(source, "Sender") + or self._pluck(source, "sender") ) - presence = str(self._pluck(event, "presence") or "").strip().lower() + chat = self._jid_to_identifier( + self._pluck(source, "Chat") or self._pluck(source, "chat") + ) + state = self._pluck(event, "State") or self._pluck(event, "state") + state_text = str(state or "").strip().lower() + is_typing = state_text in {"1", "composing", "chat_presence_composing"} for candidate in self._normalize_identifier_candidates(sender, chat): - if presence in {"composing", "typing", "recording"}: + if is_typing: await self.ur.started_typing( self.service, identifier=candidate, - payload={"presence": presence, "sender": str(sender), "chat": str(chat)}, + payload={"presence": state_text, "sender": str(sender), "chat": str(chat)}, ) - elif presence: + else: await self.ur.stopped_typing( self.service, identifier=candidate, - payload={"presence": presence, "sender": str(sender), "chat": str(chat)}, + payload={"presence": state_text, "sender": str(sender), "chat": str(chat)}, + ) + + async def _handle_presence_event(self, event): + sender = self._jid_to_identifier( + self._pluck(event, "From", "User") + or self._pluck(event, "from", "user") + ) + is_unavailable = bool( + self._pluck(event, "Unavailable") or self._pluck(event, "unavailable") + ) + + for candidate in self._normalize_identifier_candidates(sender): + if is_unavailable: + await self.ur.stopped_typing( + self.service, + identifier=candidate, + payload={"presence": "offline", "sender": str(sender)}, ) def _extract_pair_qr(self, event): + codes = self._pluck(event, "Codes") or self._pluck(event, "codes") or [] + decoded_codes = self._decode_qr_payload(codes) + if decoded_codes: + return decoded_codes + for path in ( ("qr",), ("qr_code",), @@ -537,6 +715,7 @@ class WhatsAppClient(ClientBase): return False sent_any = False + sent_ts = 0 for attachment in attachments or []: payload = await self._fetch_attachment_payload(attachment) if not payload: @@ -547,13 +726,17 @@ class WhatsAppClient(ClientBase): try: if mime.startswith("image/") and hasattr(self._client, "send_image"): - await self._maybe_await(self._client.send_image(jid, data, caption="")) + response = await self._maybe_await( + self._client.send_image(jid, data, caption="") + ) elif mime.startswith("video/") and hasattr(self._client, "send_video"): - await self._maybe_await(self._client.send_video(jid, data, caption="")) + response = await self._maybe_await( + self._client.send_video(jid, data, caption="") + ) elif mime.startswith("audio/") and hasattr(self._client, "send_audio"): - await self._maybe_await(self._client.send_audio(jid, data)) + response = await self._maybe_await(self._client.send_audio(jid, data)) elif hasattr(self._client, "send_document"): - await self._maybe_await( + response = await self._maybe_await( self._client.send_document( jid, data, @@ -562,22 +745,36 @@ class WhatsAppClient(ClientBase): caption="", ) ) + else: + response = None + sent_ts = max( + sent_ts, + self._normalize_timestamp(self._pluck(response, "Timestamp") or 0), + ) sent_any = True except Exception as exc: self.log.warning("whatsapp attachment send failed: %s", exc) if text: try: - await self._maybe_await(self._client.send_message(jid, text)) + response = await self._maybe_await(self._client.send_message(jid, text)) sent_any = True except TypeError: - await self._maybe_await(self._client.send_message(jid, message=text)) + response = await self._maybe_await( + self._client.send_message(jid, message=text) + ) sent_any = True except Exception as exc: self.log.warning("whatsapp text send failed: %s", exc) return False + sent_ts = max( + sent_ts, + self._normalize_timestamp(self._pluck(response, "Timestamp") or 0), + ) - return int(time.time() * 1000) if sent_any else False + if not sent_any: + return False + return sent_ts or int(time.time() * 1000) async def start_typing(self, identifier): if not self._client: @@ -585,14 +782,28 @@ class WhatsAppClient(ClientBase): jid = self._to_jid(identifier) if not jid: return False - for method_name in ("send_chat_presence", "set_chat_presence"): - if hasattr(self._client, method_name): - method = getattr(self._client, method_name) - try: - await self._maybe_await(method(jid, "composing")) - return True - except Exception: - continue + if ( + hasattr(self._client, "send_chat_presence") + and self._chat_presence is not None + and self._chat_presence_media is not None + ): + try: + await self._maybe_await( + self._client.send_chat_presence( + jid, + self._chat_presence.CHAT_PRESENCE_COMPOSING, + self._chat_presence_media.CHAT_PRESENCE_MEDIA_TEXT, + ) + ) + return True + except Exception: + pass + if hasattr(self._client, "set_chat_presence"): + try: + await self._maybe_await(self._client.set_chat_presence(jid, "composing")) + return True + except Exception: + pass return False async def stop_typing(self, identifier): @@ -601,14 +812,28 @@ class WhatsAppClient(ClientBase): jid = self._to_jid(identifier) if not jid: return False - for method_name in ("send_chat_presence", "set_chat_presence"): - if hasattr(self._client, method_name): - method = getattr(self._client, method_name) - try: - await self._maybe_await(method(jid, "paused")) - return True - except Exception: - continue + if ( + hasattr(self._client, "send_chat_presence") + and self._chat_presence is not None + and self._chat_presence_media is not None + ): + try: + await self._maybe_await( + self._client.send_chat_presence( + jid, + self._chat_presence.CHAT_PRESENCE_PAUSED, + self._chat_presence_media.CHAT_PRESENCE_MEDIA_TEXT, + ) + ) + return True + except Exception: + pass + if hasattr(self._client, "set_chat_presence"): + try: + await self._maybe_await(self._client.set_chat_presence(jid, "paused")) + return True + except Exception: + pass return False async def fetch_attachment(self, attachment_ref): diff --git a/core/views/whatsapp.py b/core/views/whatsapp.py index ac88d50..766664b 100644 --- a/core/views/whatsapp.py +++ b/core/views/whatsapp.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.shortcuts import render from django.views import View from mixins.views import ObjectList, ObjectRead @@ -56,9 +55,6 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList): "service_label": "WhatsApp", "account_add_url_name": "whatsapp_account_add", "show_contact_actions": False, - "endpoint_base": str( - getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080") - ).rstrip("/"), "service_warning": transport.get_service_warning("whatsapp"), } return self._normalize_accounts(transport.list_accounts("whatsapp"))