Begin WhatsApp integration

This commit is contained in:
2026-02-15 21:20:37 +00:00
parent cc3fff0757
commit ae3365e165
3 changed files with 334 additions and 75 deletions

View File

@@ -238,7 +238,23 @@ async def send_message_raw(service: str, recipient: str, text=None, attachments=
if service_key == "signal": if service_key == "signal":
return await signalapi.send_message_raw(recipient, text, attachments or []) 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) runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "send_message_raw"): if runtime_client and hasattr(runtime_client, "send_message_raw"):
try: try:
@@ -269,7 +285,18 @@ async def start_typing(service: str, recipient: str):
await signalapi.start_typing(recipient) await signalapi.start_typing(recipient)
return True 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) runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "start_typing"): if runtime_client and hasattr(runtime_client, "start_typing"):
try: try:
@@ -288,7 +315,18 @@ async def stop_typing(service: str, recipient: str):
await signalapi.stop_typing(recipient) await signalapi.stop_typing(recipient)
return True 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) runtime_client = get_runtime_client(service_key)
if runtime_client and hasattr(runtime_client, "stop_typing"): if runtime_client and hasattr(runtime_client, "stop_typing"):
try: try:

View File

@@ -30,6 +30,8 @@ class WhatsAppClient(ClientBase):
self._connected = False self._connected = False
self._last_qr_payload = "" self._last_qr_payload = ""
self._accounts = [] self._accounts = []
self._chat_presence = None
self._chat_presence_media = None
self.enabled = bool( self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower() str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -71,6 +73,11 @@ class WhatsAppClient(ClientBase):
try: try:
from neonize.aioze.client import NewAClient from neonize.aioze.client import NewAClient
from neonize.aioze import events as wa_events from neonize.aioze import events as wa_events
try:
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
except Exception:
ChatPresence = None
ChatPresenceMedia = None
try: try:
from neonize.utils import build_jid as wa_build_jid from neonize.utils import build_jid as wa_build_jid
except Exception: except Exception:
@@ -85,6 +92,8 @@ class WhatsAppClient(ClientBase):
return return
self._build_jid = wa_build_jid self._build_jid = wa_build_jid
self._chat_presence = ChatPresence
self._chat_presence_media = ChatPresenceMedia
self._client = self._build_client(NewAClient) self._client = self._build_client(NewAClient)
if self._client is None: if self._client is None:
self._publish_state( self._publish_state(
@@ -113,6 +122,59 @@ class WhatsAppClient(ClientBase):
while not self._stopping: while not self._stopping:
await asyncio.sleep(1) 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): def _build_client(self, cls):
candidates = [] candidates = []
if self.database_url: if self.database_url:
@@ -137,8 +199,12 @@ class WhatsAppClient(ClientBase):
connected_ev = getattr(wa_events, "ConnectedEv", None) connected_ev = getattr(wa_events, "ConnectedEv", None)
message_ev = getattr(wa_events, "MessageEv", None) message_ev = getattr(wa_events, "MessageEv", None)
receipt_ev = getattr(wa_events, "ReceiptEv", None) receipt_ev = getattr(wa_events, "ReceiptEv", None)
chat_presence_ev = getattr(wa_events, "ChatPresenceEv", None)
presence_ev = getattr(wa_events, "PresenceEv", None) presence_ev = getattr(wa_events, "PresenceEv", None)
pair_ev = getattr(wa_events, "PairStatusEv", 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: if connected_ev is not None:
@@ -149,30 +215,38 @@ class WhatsAppClient(ClientBase):
connected=True, connected=True,
warning="", warning="",
accounts=[account] if account else [self.client_name], 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: if message_ev is not None:
async def on_message(client, event: message_ev): async def on_message(client, event: message_ev):
await self._handle_message_event(event) await self._handle_message_event(event)
self._client.event(on_message) self._register_event(message_ev, on_message)
if receipt_ev is not None: if receipt_ev is not None:
async def on_receipt(client, event: receipt_ev): async def on_receipt(client, event: receipt_ev):
await self._handle_receipt_event(event) 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: if presence_ev is not None:
async def on_presence(client, event: presence_ev): async def on_presence(client, event: presence_ev):
await self._handle_presence_event(event) await self._handle_presence_event(event)
self._client.event(on_presence) self._register_event(presence_ev, on_presence)
if pair_ev is not None: if pair_ev is not None:
@@ -182,10 +256,41 @@ class WhatsAppClient(ClientBase):
self._last_qr_payload = qr_payload self._last_qr_payload = qr_payload
self._publish_state( self._publish_state(
pair_qr=qr_payload, 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): async def _maybe_await(self, value):
if asyncio.iscoroutine(value): if asyncio.iscoroutine(value):
@@ -202,6 +307,13 @@ class WhatsAppClient(ClientBase):
except Exception: except Exception:
return self.client_name return self.client_name
# Support both dict-like and object-like payloads. # 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 ( for path in (
("JID", "User"), ("JID", "User"),
("jid",), ("jid",),
@@ -242,7 +354,7 @@ class WhatsAppClient(ClientBase):
def _normalize_identifier_candidates(self, *values): def _normalize_identifier_candidates(self, *values):
out = set() out = set()
for value in values: for value in values:
raw = str(value or "").strip() raw = self._jid_to_identifier(value)
if not raw: if not raw:
continue continue
out.add(raw) out.add(raw)
@@ -255,6 +367,19 @@ class WhatsAppClient(ClientBase):
out.add(f"+{digits}") out.add(f"+{digits}")
return out 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): def _is_media_message(self, message_obj):
media_fields = ( media_fields = (
"imageMessage", "imageMessage",
@@ -277,7 +402,7 @@ class WhatsAppClient(ClientBase):
async def _download_event_media(self, event): async def _download_event_media(self, event):
if not self._client: if not self._client:
return [] 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): if msg_obj is None or not self._is_media_message(msg_obj):
return [] return []
if not hasattr(self._client, "download_any"): if not hasattr(self._client, "download_any"):
@@ -324,27 +449,42 @@ class WhatsAppClient(ClientBase):
] ]
async def _handle_message_event(self, event): 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 = ( text = (
self._pluck(msg_obj, "conversation") 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, "ExtendedTextMessage", "Text")
or self._pluck(msg_obj, "extended_text_message", "text") or self._pluck(msg_obj, "extended_text_message", "text")
or "" or ""
) )
source = (
sender = ( self._pluck(event, "Info", "MessageSource")
self._pluck(event, "info", "message_source", "sender") or self._pluck(event, "info", "message_source")
or self._pluck(event, "info", "messageSource", "sender") or self._pluck(event, "info", "messageSource")
or ""
) )
chat = ( is_from_me = bool(
self._pluck(event, "info", "message_source", "chat") self._pluck(source, "IsFromMe")
or self._pluck(event, "info", "messageSource", "chat") or self._pluck(source, "isFromMe")
or "" )
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 = ( 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, "info", "message_timestamp")
or self._pluck(event, "Timestamp")
or self._pluck(event, "timestamp") or self._pluck(event, "timestamp")
) )
ts = self._normalize_timestamp(raw_ts) ts = self._normalize_timestamp(raw_ts)
@@ -401,26 +541,33 @@ class WhatsAppClient(ClientBase):
) )
async def _handle_receipt_event(self, event): async def _handle_receipt_event(self, event):
sender = ( source = (
self._pluck(event, "info", "message_source", "sender") self._pluck(event, "MessageSource")
or self._pluck(event, "info", "messageSource", "sender") or self._pluck(event, "info", "message_source")
or "" or self._pluck(event, "info", "messageSource")
) )
chat = ( sender = self._jid_to_identifier(
self._pluck(event, "info", "message_source", "chat") self._pluck(source, "Sender")
or self._pluck(event, "info", "messageSource", "chat") or self._pluck(source, "sender")
or "" )
chat = self._jid_to_identifier(
self._pluck(source, "Chat") or self._pluck(source, "chat")
) )
timestamps = [] timestamps = []
raw_ids = self._pluck(event, "message_ids") or [] raw_ids = self._pluck(event, "MessageIDs") or self._pluck(event, "message_ids") or []
if isinstance(raw_ids, list): if isinstance(raw_ids, (list, tuple, set)):
for item in raw_ids: for item in raw_ids:
try: try:
value = int(item) value = int(item)
timestamps.append(value * 1000 if value < 10**12 else value) timestamps.append(value * 1000 if value < 10**12 else value)
except Exception: except Exception:
continue 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): for candidate in self._normalize_identifier_candidates(sender, chat):
await self.ur.message_read( await self.ur.message_read(
@@ -429,37 +576,68 @@ class WhatsAppClient(ClientBase):
message_timestamps=timestamps, message_timestamps=timestamps,
read_ts=read_ts, read_ts=read_ts,
read_by=sender or chat, 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): async def _handle_chat_presence_event(self, event):
sender = ( source = (
self._pluck(event, "message_source", "sender") self._pluck(event, "MessageSource")
or self._pluck(event, "info", "message_source", "sender") or self._pluck(event, "message_source")
or "" or {}
) )
chat = ( sender = self._jid_to_identifier(
self._pluck(event, "message_source", "chat") self._pluck(source, "Sender")
or self._pluck(event, "info", "message_source", "chat") or self._pluck(source, "sender")
or ""
) )
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): for candidate in self._normalize_identifier_candidates(sender, chat):
if presence in {"composing", "typing", "recording"}: if is_typing:
await self.ur.started_typing( await self.ur.started_typing(
self.service, self.service,
identifier=candidate, 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( await self.ur.stopped_typing(
self.service, self.service,
identifier=candidate, 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): 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 ( for path in (
("qr",), ("qr",),
("qr_code",), ("qr_code",),
@@ -537,6 +715,7 @@ class WhatsAppClient(ClientBase):
return False return False
sent_any = False sent_any = False
sent_ts = 0
for attachment in attachments or []: for attachment in attachments or []:
payload = await self._fetch_attachment_payload(attachment) payload = await self._fetch_attachment_payload(attachment)
if not payload: if not payload:
@@ -547,13 +726,17 @@ class WhatsAppClient(ClientBase):
try: try:
if mime.startswith("image/") and hasattr(self._client, "send_image"): 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"): 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"): 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"): elif hasattr(self._client, "send_document"):
await self._maybe_await( response = await self._maybe_await(
self._client.send_document( self._client.send_document(
jid, jid,
data, data,
@@ -562,22 +745,36 @@ class WhatsAppClient(ClientBase):
caption="", caption="",
) )
) )
else:
response = None
sent_ts = max(
sent_ts,
self._normalize_timestamp(self._pluck(response, "Timestamp") or 0),
)
sent_any = True sent_any = True
except Exception as exc: except Exception as exc:
self.log.warning("whatsapp attachment send failed: %s", exc) self.log.warning("whatsapp attachment send failed: %s", exc)
if text: if text:
try: 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 sent_any = True
except TypeError: 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 sent_any = True
except Exception as exc: except Exception as exc:
self.log.warning("whatsapp text send failed: %s", exc) self.log.warning("whatsapp text send failed: %s", exc)
return False 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): async def start_typing(self, identifier):
if not self._client: if not self._client:
@@ -585,14 +782,28 @@ class WhatsAppClient(ClientBase):
jid = self._to_jid(identifier) jid = self._to_jid(identifier)
if not jid: if not jid:
return False return False
for method_name in ("send_chat_presence", "set_chat_presence"): if (
if hasattr(self._client, method_name): hasattr(self._client, "send_chat_presence")
method = getattr(self._client, method_name) and self._chat_presence is not None
and self._chat_presence_media is not None
):
try: try:
await self._maybe_await(method(jid, "composing")) 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 return True
except Exception: except Exception:
continue 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 return False
async def stop_typing(self, identifier): async def stop_typing(self, identifier):
@@ -601,14 +812,28 @@ class WhatsAppClient(ClientBase):
jid = self._to_jid(identifier) jid = self._to_jid(identifier)
if not jid: if not jid:
return False return False
for method_name in ("send_chat_presence", "set_chat_presence"): if (
if hasattr(self._client, method_name): hasattr(self._client, "send_chat_presence")
method = getattr(self._client, method_name) and self._chat_presence is not None
and self._chat_presence_media is not None
):
try: try:
await self._maybe_await(method(jid, "paused")) 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 return True
except Exception: except Exception:
continue 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 return False
async def fetch_attachment(self, attachment_ref): async def fetch_attachment(self, attachment_ref):

View File

@@ -1,4 +1,3 @@
from django.conf import settings
from django.shortcuts import render from django.shortcuts import render
from django.views import View from django.views import View
from mixins.views import ObjectList, ObjectRead from mixins.views import ObjectList, ObjectRead
@@ -56,9 +55,6 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
"service_label": "WhatsApp", "service_label": "WhatsApp",
"account_add_url_name": "whatsapp_account_add", "account_add_url_name": "whatsapp_account_add",
"show_contact_actions": False, "show_contact_actions": False,
"endpoint_base": str(
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
).rstrip("/"),
"service_warning": transport.get_service_warning("whatsapp"), "service_warning": transport.get_service_warning("whatsapp"),
} }
return self._normalize_accounts(transport.list_accounts("whatsapp")) return self._normalize_accounts(transport.list_accounts("whatsapp"))