Improve insights and continue WhatsApp implementation

This commit is contained in:
2026-02-15 23:02:51 +00:00
parent b23af9bc7f
commit 88224d972c
13 changed files with 628 additions and 81 deletions

View File

@@ -1,6 +1,7 @@
import asyncio
import re
import time
from urllib.parse import quote_plus
import aiohttp
from asgiref.sync import sync_to_async
@@ -33,6 +34,10 @@ class WhatsAppClient(ClientBase):
self._chat_presence = None
self._chat_presence_media = None
self._last_pair_request = 0
self._next_qr_probe_at = 0.0
self._qr_handler_registered = False
self._qr_handler_supported = False
self._event_hook_callable = False
self.enabled = bool(
str(getattr(settings, "WHATSAPP_ENABLED", "false")).lower()
@@ -54,6 +59,7 @@ class WhatsAppClient(ClientBase):
else ""
),
accounts=[],
last_event="init",
)
def _publish_state(self, **updates):
@@ -88,6 +94,8 @@ class WhatsAppClient(ClientBase):
connected=False,
warning=f"Neonize not available: {exc}",
accounts=[],
last_event="neonize_import_failed",
last_error=str(exc),
)
self.log.warning("whatsapp neonize import failed: %s", exc)
return
@@ -101,6 +109,8 @@ class WhatsAppClient(ClientBase):
connected=False,
warning="Failed to initialize Neonize client.",
accounts=[],
last_event="client_init_failed",
last_error="client_none",
)
return
@@ -108,6 +118,7 @@ class WhatsAppClient(ClientBase):
try:
await self._maybe_await(self._client.connect())
await self._after_connect_probe()
except asyncio.CancelledError:
raise
except Exception as exc:
@@ -115,13 +126,22 @@ class WhatsAppClient(ClientBase):
connected=False,
warning=f"WhatsApp connect failed: {exc}",
accounts=[],
last_event="connect_failed",
last_error=str(exc),
)
self.log.warning("whatsapp connect failed: %s", exc)
return
# Keep task alive so state/callbacks remain active.
next_heartbeat_at = 0.0
while not self._stopping:
now = time.time()
if now >= next_heartbeat_at:
self._publish_state(runtime_seen_at=int(now))
next_heartbeat_at = now + 5.0
self._mark_qr_wait_timeout()
await self._sync_pair_request()
await self._probe_pending_qr(now)
await asyncio.sleep(1)
async def _sync_pair_request(self):
@@ -134,7 +154,12 @@ class WhatsAppClient(ClientBase):
connected=False,
pair_qr="",
warning="Waiting for WhatsApp QR from Neonize.",
last_event="pair_request_seen",
pair_status="pending",
last_error="",
pair_reconnect_attempted_at=int(time.time()),
)
self._next_qr_probe_at = time.time()
if self._client is None:
return
@@ -143,38 +168,147 @@ class WhatsAppClient(ClientBase):
if hasattr(self._client, "disconnect"):
await self._maybe_await(self._client.disconnect())
except Exception as exc:
self._publish_state(
last_event="pair_disconnect_failed",
last_error=str(exc),
)
self.log.warning("whatsapp disconnect before pairing failed: %s", exc)
try:
await self._maybe_await(self._client.connect())
self._publish_state(
last_event="pair_refresh_connected",
pair_refresh_connected_at=int(time.time()),
)
await self._after_connect_probe()
except Exception as exc:
self._publish_state(
connected=False,
warning=f"WhatsApp pairing refresh failed: {exc}",
last_event="pair_refresh_failed",
last_error=str(exc),
)
self.log.warning("whatsapp pairing refresh failed: %s", exc)
def _register_event(self, event_cls, callback):
if event_cls is None:
async def _probe_pending_qr(self, now_ts: float):
state = transport.get_runtime_state(self.service)
status = str(state.get("pair_status") or "").strip().lower()
if status != "pending":
return
if str(state.get("pair_qr") or "").strip():
return
if now_ts < float(self._next_qr_probe_at or 0.0):
return
await self._after_connect_probe()
latest = transport.get_runtime_state(self.service)
if str(latest.get("pair_qr") or "").strip():
self._next_qr_probe_at = now_ts + 30.0
return
error_text = str(latest.get("last_error") or "").strip().lower()
# Neonize may report "client is nil" for a few seconds after connect.
if "client is nil" in error_text:
self._next_qr_probe_at = now_ts + 2.0
return
self._next_qr_probe_at = now_ts + 5.0
async def _after_connect_probe(self):
if self._client is None:
return
now_ts = int(time.time())
try:
if hasattr(self._client, "is_connected"):
connected_value = await self._maybe_await(self._client.is_connected())
if connected_value:
self._connected = True
self._publish_state(
connected=True,
pair_status="connected",
last_event="connected_probe",
connected_at=now_ts,
)
except Exception as exc:
self._publish_state(
last_event="connected_probe_failed",
last_error=str(exc),
)
# Neonize does not always emit QR callbacks after reconnect. Try explicit
# QR-link fetch when available to surface pair data to the UI.
try:
if hasattr(self._client, "get_contact_qr_link"):
qr_link = await self._maybe_await(self._client.get_contact_qr_link())
qr_payload = self._decode_qr_payload(qr_link)
self._publish_state(last_qr_probe_at=now_ts)
if qr_payload:
self._last_qr_payload = qr_payload
self._publish_state(
connected=False,
pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_probe_success",
pair_status="qr_ready",
qr_received_at=now_ts,
qr_probe_result="ok",
last_error="",
)
else:
self._publish_state(
last_event="qr_probe_empty",
qr_probe_result="empty",
)
except Exception as exc:
self._publish_state(
last_event="qr_probe_failed",
qr_probe_result="error",
last_error=str(exc),
last_qr_probe_at=now_ts,
)
def _register_event(self, event_cls, callback):
if event_cls is None:
return False
if self._client is None:
return False
event_hook = getattr(self._client, "event", None)
if not callable(event_hook):
return
self._event_hook_callable = False
return False
self._event_hook_callable = True
try:
decorator = event_hook(event_cls)
decorator(callback)
return True
except Exception as exc:
self.log.warning(
"whatsapp event registration failed (%s): %s",
getattr(event_cls, "__name__", str(event_cls)),
exc,
)
self._publish_state(
last_event="event_registration_failed",
last_error=str(exc),
)
return False
def _register_qr_handler(self):
if self._client is None or not hasattr(self._client, "qr"):
if self._client is None:
self._qr_handler_supported = False
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=False,
qr_handler_registered=False,
)
return
if not hasattr(self._client, "qr"):
self._qr_handler_supported = False
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=False,
qr_handler_registered=False,
last_event="qr_api_missing",
)
return
self._qr_handler_supported = True
async def on_qr(client, raw_payload):
qr_payload = self._decode_qr_payload(raw_payload)
@@ -185,11 +319,28 @@ class WhatsAppClient(ClientBase):
connected=False,
pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_handler",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
)
try:
self._client.qr(on_qr)
self._qr_handler_registered = True
self._publish_state(
qr_handler_supported=True,
qr_handler_registered=True,
last_event="qr_handler_registered",
)
except Exception as exc:
self._qr_handler_registered = False
self._publish_state(
qr_handler_supported=True,
qr_handler_registered=False,
last_event="qr_handler_registration_failed",
last_error=str(exc),
)
self.log.warning("whatsapp qr handler registration failed: %s", exc)
def _decode_qr_payload(self, raw_payload):
@@ -237,6 +388,18 @@ class WhatsAppClient(ClientBase):
qr_ev = getattr(wa_events, "QREv", None)
self._register_qr_handler()
support = {
"connected_ev": bool(connected_ev),
"pair_ev": bool(pair_ev),
"qr_ev": bool(qr_ev),
"message_ev": bool(message_ev),
"receipt_ev": bool(receipt_ev),
}
self._publish_state(
event_hook_callable=bool(getattr(self._client, "event", None)),
event_support=support,
last_event="event_handlers_scanned",
)
if connected_ev is not None:
@@ -248,6 +411,10 @@ class WhatsAppClient(ClientBase):
warning="",
accounts=[account] if account else [self.client_name],
pair_qr="",
last_event="connected",
pair_status="connected",
connected_at=int(time.time()),
last_error="",
)
self._register_event(connected_ev, on_connected)
@@ -289,6 +456,10 @@ class WhatsAppClient(ClientBase):
self._publish_state(
pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.",
last_event="pair_status_qr",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
)
status_raw = self._pluck(event, "Status")
status_text = str(status_raw or "").strip().lower()
@@ -300,11 +471,18 @@ class WhatsAppClient(ClientBase):
warning="",
accounts=[account] if account else [self.client_name],
pair_qr="",
last_event="pair_status_success",
pair_status="connected",
connected_at=int(time.time()),
last_error="",
)
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.",
last_event="pair_status_error",
pair_status="error",
last_error=error_text or "unknown_pair_error",
)
self._register_event(pair_ev, on_pair_status)
@@ -320,10 +498,35 @@ class WhatsAppClient(ClientBase):
connected=False,
pair_qr=qr_payload,
warning="Scan QR in WhatsApp Linked Devices.",
last_event="qr_event",
pair_status="qr_ready",
qr_received_at=int(time.time()),
last_error="",
)
self._register_event(qr_ev, on_qr_event)
def _mark_qr_wait_timeout(self):
state = transport.get_runtime_state(self.service)
if str(state.get("pair_status") or "").strip().lower() != "pending":
return
requested_at = int(state.get("pair_requested_at") or 0)
qr_received_at = int(state.get("qr_received_at") or 0)
if requested_at <= 0 or qr_received_at > 0:
return
now = int(time.time())
age = now - requested_at
# Avoid spamming writes while still surfacing a clear timeout state.
if age < 15 or (age % 10) != 0:
return
self._publish_state(
last_event="pair_waiting_no_qr",
warning=(
"Waiting for WhatsApp QR from Neonize. "
"No QR callback received yet."
),
)
async def _maybe_await(self, value):
if asyncio.iscoroutine(value):
return await value
@@ -549,25 +752,37 @@ class WhatsAppClient(ClientBase):
}
for identifier in identifiers:
session = await history.get_chat_session(identifier.user, identifier)
await history.store_message(
session=session,
sender=str(sender or chat or ""),
text=text,
ts=ts,
outgoing=False,
)
await self.ur.xmpp.client.send_from_external(
uploaded_urls = await self.ur.xmpp.client.send_from_external(
identifier.user,
identifier,
text,
is_outgoing_message=False,
attachments=xmpp_attachments,
)
display_text = text
if (not display_text) and uploaded_urls:
display_text = "\n".join(uploaded_urls)
if (not display_text) and attachments:
media_urls = [
self._blob_key_to_compose_url((att or {}).get("blob_key"))
for att in attachments
]
media_urls = [url for url in media_urls if url]
if media_urls:
display_text = "\n".join(media_urls)
session = await history.get_chat_session(identifier.user, identifier)
await history.store_message(
session=session,
sender=str(sender or chat or ""),
text=display_text,
ts=ts,
outgoing=False,
)
await self.ur.message_received(
self.service,
identifier=identifier,
text=text,
text=display_text,
ts=ts,
payload=payload,
)
@@ -699,6 +914,12 @@ class WhatsAppClient(ClientBase):
return f"{digits}@s.whatsapp.net"
return raw
def _blob_key_to_compose_url(self, blob_key):
key = str(blob_key or "").strip()
if not key:
return ""
return f"/compose/media/blob/?key={quote_plus(key)}"
async def _fetch_attachment_payload(self, attachment):
blob_key = (attachment or {}).get("blob_key")
if blob_key: