Continue implementing WhatsApp
This commit is contained in:
@@ -3,6 +3,7 @@ import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote_plus
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -99,11 +100,94 @@ def list_accounts(service: str):
|
||||
|
||||
state = get_runtime_state(service_key)
|
||||
accounts = state.get("accounts") or []
|
||||
if service_key == "whatsapp" and not accounts:
|
||||
contacts = state.get("contacts") or []
|
||||
recovered = []
|
||||
seen = set()
|
||||
for row in contacts:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
candidate = str(row.get("identifier") or row.get("jid") or "").strip()
|
||||
if not candidate or candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
recovered.append(candidate)
|
||||
if recovered:
|
||||
accounts = recovered
|
||||
update_runtime_state(service_key, accounts=recovered)
|
||||
if isinstance(accounts, list):
|
||||
return accounts
|
||||
return []
|
||||
|
||||
|
||||
def _account_key(value: str) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if "@" in raw:
|
||||
raw = raw.split("@", 1)[0]
|
||||
return raw
|
||||
|
||||
|
||||
def unlink_account(service: str, account: str) -> bool:
|
||||
service_key = _service_key(service)
|
||||
account_value = str(account or "").strip()
|
||||
if not account_value:
|
||||
return False
|
||||
|
||||
if service_key == "signal":
|
||||
import requests
|
||||
|
||||
base = str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip("/")
|
||||
target = quote_plus(account_value)
|
||||
for path in (f"/v1/accounts/{target}", f"/v1/account/{target}"):
|
||||
try:
|
||||
response = requests.delete(f"{base}{path}", timeout=20)
|
||||
if response.ok:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
if service_key in {"whatsapp", "instagram"}:
|
||||
state = get_runtime_state(service_key)
|
||||
key = _account_key(account_value)
|
||||
|
||||
raw_accounts = state.get("accounts") or []
|
||||
accounts = []
|
||||
for row in raw_accounts:
|
||||
value = str(row or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
if _account_key(value) == key:
|
||||
continue
|
||||
accounts.append(value)
|
||||
|
||||
raw_contacts = state.get("contacts") or []
|
||||
contacts = []
|
||||
for row in raw_contacts:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
identifier = str(row.get("identifier") or "").strip()
|
||||
jid = str(row.get("jid") or "").strip()
|
||||
if _account_key(identifier) == key or _account_key(jid) == key:
|
||||
continue
|
||||
contacts.append(row)
|
||||
|
||||
update_runtime_state(
|
||||
service_key,
|
||||
accounts=accounts,
|
||||
contacts=contacts,
|
||||
connected=bool(accounts),
|
||||
pair_status=("connected" if accounts else ""),
|
||||
pair_qr="",
|
||||
warning=("" if accounts else "Account unlinked. Add account to link again."),
|
||||
last_event="account_unlinked",
|
||||
last_error="",
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_service_warning(service: str) -> str:
|
||||
service_key = _service_key(service)
|
||||
if service_key == "signal":
|
||||
@@ -131,6 +215,18 @@ def request_pairing(service: str, device_name: str = ""):
|
||||
service_key = _service_key(service)
|
||||
if service_key not in {"whatsapp", "instagram"}:
|
||||
return
|
||||
state = get_runtime_state(service_key)
|
||||
existing_accounts = state.get("accounts") or []
|
||||
is_connected = bool(state.get("connected"))
|
||||
pair_status = str(state.get("pair_status") or "").strip().lower()
|
||||
if existing_accounts and (is_connected or pair_status == "connected"):
|
||||
update_runtime_state(
|
||||
service_key,
|
||||
warning="Account already linked.",
|
||||
pair_status="connected",
|
||||
pair_qr="",
|
||||
)
|
||||
return
|
||||
device = str(device_name or "GIA Device").strip() or "GIA Device"
|
||||
update_runtime_state(
|
||||
service_key,
|
||||
@@ -454,6 +550,17 @@ def get_link_qr(service: str, device_name: str):
|
||||
return cached
|
||||
|
||||
if service_key == "whatsapp":
|
||||
state = get_runtime_state(service_key)
|
||||
existing_accounts = state.get("accounts") or []
|
||||
pair_status = str(state.get("pair_status") or "").strip().lower()
|
||||
if existing_accounts and (
|
||||
bool(state.get("connected")) or pair_status == "connected"
|
||||
):
|
||||
raise RuntimeError(
|
||||
"WhatsApp account already linked in this runtime. "
|
||||
"Only one active linked device is supported. "
|
||||
"Unlink the current account first, then add a new one."
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Neonize has not provided a pairing QR yet. "
|
||||
"Ensure UR is running with WHATSAPP_ENABLED=true and retry."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
@@ -51,9 +52,21 @@ class WhatsAppClient(ClientBase):
|
||||
getattr(settings, "WHATSAPP_DATABASE_URL", "")
|
||||
).strip()
|
||||
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp"
|
||||
self.session_db = self.database_url or f"/tmp/{safe_name}.db"
|
||||
# Use a persistent default path (under project mount) instead of /tmp so
|
||||
# link state and contact cache survive container restarts.
|
||||
default_db_dir = str(
|
||||
getattr(settings, "WHATSAPP_DB_DIR", "/var/tmp/whatsapp")
|
||||
).strip()
|
||||
self.session_db = self.database_url or os.path.join(
|
||||
default_db_dir,
|
||||
f"{safe_name}_neonize_v2.db",
|
||||
)
|
||||
|
||||
transport.register_runtime_client(self.service, self)
|
||||
prior_state = transport.get_runtime_state(self.service)
|
||||
prior_accounts = prior_state.get("accounts")
|
||||
if not isinstance(prior_accounts, list):
|
||||
prior_accounts = []
|
||||
self._publish_state(
|
||||
connected=False,
|
||||
warning=(
|
||||
@@ -61,7 +74,7 @@ class WhatsAppClient(ClientBase):
|
||||
if not self.enabled
|
||||
else ""
|
||||
),
|
||||
accounts=[],
|
||||
accounts=prior_accounts,
|
||||
last_event="init",
|
||||
session_db=self.session_db,
|
||||
)
|
||||
@@ -171,11 +184,15 @@ class WhatsAppClient(ClientBase):
|
||||
|
||||
# Keep task alive so state/callbacks remain active.
|
||||
next_heartbeat_at = 0.0
|
||||
next_contacts_sync_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
|
||||
if now >= next_contacts_sync_at:
|
||||
await self._sync_contacts_from_client()
|
||||
next_contacts_sync_at = now + 30.0
|
||||
self._mark_qr_wait_timeout()
|
||||
await self._sync_pair_request()
|
||||
await self._probe_pending_qr(now)
|
||||
@@ -279,6 +296,9 @@ class WhatsAppClient(ClientBase):
|
||||
last_error=str(exc),
|
||||
)
|
||||
|
||||
if self._connected:
|
||||
await self._sync_contacts_from_client()
|
||||
|
||||
# Neonize does not always emit QR callbacks after reconnect. Try explicit
|
||||
# QR-link fetch when available to surface pair data to the UI.
|
||||
try:
|
||||
@@ -427,6 +447,8 @@ class WhatsAppClient(ClientBase):
|
||||
presence_ev = getattr(wa_events, "PresenceEv", None)
|
||||
pair_ev = getattr(wa_events, "PairStatusEv", None)
|
||||
qr_ev = getattr(wa_events, "QREv", None)
|
||||
history_sync_ev = getattr(wa_events, "HistorySyncEv", None)
|
||||
offline_sync_completed_ev = getattr(wa_events, "OfflineSyncCompletedEv", None)
|
||||
|
||||
self._register_qr_handler()
|
||||
support = {
|
||||
@@ -435,6 +457,8 @@ class WhatsAppClient(ClientBase):
|
||||
"qr_ev": bool(qr_ev),
|
||||
"message_ev": bool(message_ev),
|
||||
"receipt_ev": bool(receipt_ev),
|
||||
"history_sync_ev": bool(history_sync_ev),
|
||||
"offline_sync_completed_ev": bool(offline_sync_completed_ev),
|
||||
}
|
||||
self._publish_state(
|
||||
event_hook_callable=bool(getattr(self._client, "event", None)),
|
||||
@@ -447,12 +471,6 @@ class WhatsAppClient(ClientBase):
|
||||
async def on_connected(client, event: connected_ev):
|
||||
self._connected = True
|
||||
account = await self._resolve_account_identifier()
|
||||
if account:
|
||||
self._remember_contact(
|
||||
account,
|
||||
jid=account,
|
||||
name="Linked Account",
|
||||
)
|
||||
self._publish_state(
|
||||
connected=True,
|
||||
warning="",
|
||||
@@ -463,6 +481,7 @@ class WhatsAppClient(ClientBase):
|
||||
connected_at=int(time.time()),
|
||||
last_error="",
|
||||
)
|
||||
await self._sync_contacts_from_client()
|
||||
|
||||
self._register_event(connected_ev, on_connected)
|
||||
|
||||
@@ -513,12 +532,6 @@ class WhatsAppClient(ClientBase):
|
||||
status_text = str(status_raw or "").strip().lower()
|
||||
if status_text in {"2", "success"}:
|
||||
account = await self._resolve_account_identifier()
|
||||
if account:
|
||||
self._remember_contact(
|
||||
account,
|
||||
jid=account,
|
||||
name="Linked Account",
|
||||
)
|
||||
self._connected = True
|
||||
self._publish_state(
|
||||
connected=True,
|
||||
@@ -530,6 +543,7 @@ class WhatsAppClient(ClientBase):
|
||||
connected_at=int(time.time()),
|
||||
last_error="",
|
||||
)
|
||||
await self._sync_contacts_from_client()
|
||||
elif status_text in {"1", "error"}:
|
||||
error_text = str(self._pluck(event, "Error") or "").strip()
|
||||
self._publish_state(
|
||||
@@ -544,6 +558,11 @@ class WhatsAppClient(ClientBase):
|
||||
if qr_ev is not None:
|
||||
|
||||
async def on_qr_event(client, event: qr_ev):
|
||||
# Once connected, ignore late/stale QR emissions so runtime state
|
||||
# does not regress from connected -> qr_ready.
|
||||
state = transport.get_runtime_state(self.service)
|
||||
if self._connected or bool(state.get("connected")):
|
||||
return
|
||||
qr_payload = self._extract_pair_qr(event)
|
||||
if not qr_payload:
|
||||
return
|
||||
@@ -561,6 +580,21 @@ class WhatsAppClient(ClientBase):
|
||||
|
||||
self._register_event(qr_ev, on_qr_event)
|
||||
|
||||
if history_sync_ev is not None:
|
||||
|
||||
async def on_history_sync(client, event: history_sync_ev):
|
||||
await self._handle_history_sync_event(event)
|
||||
|
||||
self._register_event(history_sync_ev, on_history_sync)
|
||||
|
||||
if offline_sync_completed_ev is not None:
|
||||
|
||||
async def on_offline_sync_completed(client, event: offline_sync_completed_ev):
|
||||
self._publish_state(last_event="offline_sync_completed")
|
||||
await self._sync_contacts_from_client()
|
||||
|
||||
self._register_event(offline_sync_completed_ev, on_offline_sync_completed)
|
||||
|
||||
def _mark_qr_wait_timeout(self):
|
||||
state = transport.get_runtime_state(self.service)
|
||||
if str(state.get("pair_status") or "").strip().lower() != "pending":
|
||||
@@ -613,6 +647,15 @@ class WhatsAppClient(ClientBase):
|
||||
value = self._pluck(me, *path)
|
||||
if value:
|
||||
return str(value)
|
||||
if hasattr(self._client, "get_all_devices"):
|
||||
try:
|
||||
devices = await self._maybe_await(self._client.get_all_devices())
|
||||
if devices:
|
||||
jid = self._jid_to_identifier(self._pluck(devices[0], "JID"))
|
||||
if jid:
|
||||
return jid
|
||||
except Exception:
|
||||
pass
|
||||
return self.client_name
|
||||
|
||||
def _pluck(self, obj, *path):
|
||||
@@ -657,8 +700,283 @@ class WhatsAppClient(ClientBase):
|
||||
out.add(f"+{digits}")
|
||||
return out
|
||||
|
||||
async def _sync_contacts_from_client(self):
|
||||
if self._client is None:
|
||||
return
|
||||
connected_now = await self._is_contact_sync_ready()
|
||||
if not connected_now:
|
||||
self._publish_state(
|
||||
last_event="contacts_sync_skipped_disconnected",
|
||||
contacts_source="disconnected",
|
||||
)
|
||||
return
|
||||
# NOTE: Neonize get_all_contacts has crashed some runtime builds with a Go panic.
|
||||
# Read contact-like rows directly from the session sqlite DB instead.
|
||||
contacts, source = await self._sync_contacts_from_sqlite()
|
||||
if not contacts:
|
||||
self.log.info("whatsapp contacts sync empty (%s)", source or "unknown")
|
||||
self._publish_state(
|
||||
last_event="contacts_sync_empty",
|
||||
contacts_source=source or "unknown",
|
||||
)
|
||||
return
|
||||
self.log.info(
|
||||
"whatsapp contacts synced: count=%s source=%s",
|
||||
len(contacts),
|
||||
source or "unknown",
|
||||
)
|
||||
self._publish_state(
|
||||
contacts=contacts,
|
||||
contacts_synced_at=int(time.time()),
|
||||
contacts_sync_count=len(contacts),
|
||||
last_event="contacts_synced",
|
||||
contacts_source=source or "unknown",
|
||||
last_error="",
|
||||
)
|
||||
|
||||
async def _sync_contacts_from_sqlite(self):
|
||||
def _extract():
|
||||
if not self.session_db or not os.path.exists(self.session_db):
|
||||
return [], "sqlite_missing"
|
||||
try:
|
||||
conn = sqlite3.connect(self.session_db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except Exception:
|
||||
return [], "sqlite_open_failed"
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
table_rows = cur.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
table_names = [str(row[0]) for row in table_rows if row and row[0]]
|
||||
account_keys = {
|
||||
str(value or "").strip().split("@", 1)[0].lower()
|
||||
for value in (
|
||||
transport.get_runtime_state(self.service).get("accounts") or []
|
||||
)
|
||||
if str(value or "").strip()
|
||||
}
|
||||
seen = set()
|
||||
out = []
|
||||
for table in table_names:
|
||||
try:
|
||||
columns = [
|
||||
str(row[1] or "")
|
||||
for row in cur.execute(
|
||||
f'PRAGMA table_info("{table}")'
|
||||
).fetchall()
|
||||
]
|
||||
except Exception:
|
||||
continue
|
||||
if not columns:
|
||||
continue
|
||||
jid_cols = [
|
||||
col
|
||||
for col in columns
|
||||
if any(
|
||||
token in col.lower()
|
||||
for token in (
|
||||
"jid",
|
||||
"phone",
|
||||
"chat",
|
||||
"sender",
|
||||
"recipient",
|
||||
"participant",
|
||||
"from",
|
||||
"to",
|
||||
"user",
|
||||
)
|
||||
)
|
||||
]
|
||||
name_cols = [
|
||||
col
|
||||
for col in columns
|
||||
if "name" in col.lower() or "push" in col.lower()
|
||||
]
|
||||
if not jid_cols:
|
||||
continue
|
||||
select_cols = list(dict.fromkeys(jid_cols + name_cols))[:6]
|
||||
quoted = ", ".join(f'"{col}"' for col in select_cols)
|
||||
try:
|
||||
rows = cur.execute(
|
||||
f'SELECT {quoted} FROM "{table}" LIMIT 1000'
|
||||
).fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
for row in rows:
|
||||
row_map = {col: row[idx] for idx, col in enumerate(select_cols)}
|
||||
jid_value = ""
|
||||
for col in jid_cols:
|
||||
raw = str(row_map.get(col) or "").strip()
|
||||
if "@s.whatsapp.net" in raw:
|
||||
m = re.search(r"([0-9]{6,20})@s\.whatsapp\.net", raw)
|
||||
jid_value = (
|
||||
f"{m.group(1)}@s.whatsapp.net"
|
||||
if m
|
||||
else raw.split()[0]
|
||||
)
|
||||
break
|
||||
if raw.endswith("@lid"):
|
||||
jid_value = raw
|
||||
break
|
||||
digits = re.sub(r"[^0-9]", "", raw)
|
||||
if digits:
|
||||
jid_value = f"{digits}@s.whatsapp.net"
|
||||
break
|
||||
if not jid_value:
|
||||
continue
|
||||
identifier = jid_value.split("@", 1)[0].strip()
|
||||
if not identifier:
|
||||
continue
|
||||
if identifier.lower() in account_keys:
|
||||
continue
|
||||
if identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
display_name = ""
|
||||
for col in name_cols:
|
||||
candidate = str(row_map.get(col) or "").strip()
|
||||
if candidate and candidate not in {"~", "-", "_"}:
|
||||
display_name = candidate
|
||||
break
|
||||
out.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": jid_value,
|
||||
"name": display_name,
|
||||
"chat": "",
|
||||
"seen_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
if len(out) >= 500:
|
||||
return out, "sqlite_tables"
|
||||
return out, "sqlite_tables"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return await asyncio.to_thread(_extract)
|
||||
|
||||
async def _is_contact_sync_ready(self) -> bool:
|
||||
if self._client is None:
|
||||
return False
|
||||
if self._connected:
|
||||
return True
|
||||
state = transport.get_runtime_state(self.service)
|
||||
if bool(state.get("connected")):
|
||||
return True
|
||||
pair_status = str(state.get("pair_status") or "").strip().lower()
|
||||
if pair_status == "connected":
|
||||
return True
|
||||
check_connected = getattr(self._client, "is_connected", None)
|
||||
if check_connected is None:
|
||||
return False
|
||||
try:
|
||||
value = (
|
||||
await self._maybe_await(check_connected())
|
||||
if callable(check_connected)
|
||||
else await self._maybe_await(check_connected)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
if value:
|
||||
self._connected = True
|
||||
self._publish_state(connected=True, warning="", pair_status="connected")
|
||||
return True
|
||||
if hasattr(self._client, "get_me"):
|
||||
try:
|
||||
me = await self._maybe_await(self._client.get_me())
|
||||
if me:
|
||||
self._connected = True
|
||||
self._publish_state(connected=True, warning="", pair_status="connected")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
async def _handle_history_sync_event(self, event):
|
||||
data = self._pluck(event, "Data") or self._pluck(event, "data")
|
||||
if data is None:
|
||||
return
|
||||
|
||||
pushname_rows = (
|
||||
self._pluck(data, "pushnames")
|
||||
or self._pluck(data, "Pushnames")
|
||||
or []
|
||||
)
|
||||
pushname_map = {}
|
||||
for row in pushname_rows:
|
||||
raw_id = (
|
||||
self._jid_to_identifier(self._pluck(row, "ID"))
|
||||
or self._jid_to_identifier(self._pluck(row, "id"))
|
||||
)
|
||||
if not raw_id:
|
||||
continue
|
||||
pushname = str(
|
||||
self._pluck(row, "pushname")
|
||||
or self._pluck(row, "Pushname")
|
||||
or ""
|
||||
).strip()
|
||||
if not pushname:
|
||||
continue
|
||||
pushname_map[raw_id] = pushname
|
||||
pushname_map[raw_id.split("@", 1)[0]] = pushname
|
||||
|
||||
conversation_rows = (
|
||||
self._pluck(data, "conversations")
|
||||
or self._pluck(data, "Conversations")
|
||||
or []
|
||||
)
|
||||
found = 0
|
||||
for row in conversation_rows:
|
||||
jid = ""
|
||||
for candidate in (
|
||||
self._pluck(row, "ID"),
|
||||
self._pluck(row, "id"),
|
||||
self._pluck(row, "pnJID"),
|
||||
self._pluck(row, "pnJid"),
|
||||
self._pluck(row, "newJID"),
|
||||
self._pluck(row, "newJid"),
|
||||
self._pluck(row, "oldJID"),
|
||||
self._pluck(row, "oldJid"),
|
||||
):
|
||||
parsed = self._jid_to_identifier(candidate)
|
||||
if parsed:
|
||||
jid = parsed
|
||||
break
|
||||
if not jid or "@g.us" in jid or "@broadcast" in jid:
|
||||
continue
|
||||
identifier = jid.split("@", 1)[0].strip()
|
||||
if not identifier or not re.search(r"[0-9]{6,}", identifier):
|
||||
continue
|
||||
name = str(
|
||||
self._pluck(row, "displayName")
|
||||
or self._pluck(row, "DisplayName")
|
||||
or self._pluck(row, "name")
|
||||
or self._pluck(row, "Name")
|
||||
or self._pluck(row, "username")
|
||||
or self._pluck(row, "Username")
|
||||
or pushname_map.get(jid)
|
||||
or pushname_map.get(identifier)
|
||||
or ""
|
||||
).strip()
|
||||
self._remember_contact(identifier, jid=jid, name=name)
|
||||
found += 1
|
||||
|
||||
if found:
|
||||
state = transport.get_runtime_state(self.service)
|
||||
current_count = int(state.get("contacts_sync_count") or 0)
|
||||
self._publish_state(
|
||||
contacts_source="history_sync",
|
||||
contacts_synced_at=int(time.time()),
|
||||
contacts_sync_count=max(current_count, found),
|
||||
last_event="history_sync_contacts",
|
||||
last_error="",
|
||||
)
|
||||
|
||||
def _remember_contact(self, identifier, *, jid="", name="", chat=""):
|
||||
cleaned = str(identifier or "").strip()
|
||||
if "@" in cleaned:
|
||||
cleaned = cleaned.split("@", 1)[0]
|
||||
if not cleaned:
|
||||
return
|
||||
state = transport.get_runtime_state(self.service)
|
||||
|
||||
Reference in New Issue
Block a user