Continue implementing WhatsApp
This commit is contained in:
@@ -119,6 +119,11 @@ urlpatterns = [
|
||||
whatsapp.WhatsAppAccountAdd.as_view(),
|
||||
name="whatsapp_account_add",
|
||||
),
|
||||
path(
|
||||
"services/whatsapp/<str:type>/unlink/<path:account>/",
|
||||
whatsapp.WhatsAppAccountUnlink.as_view(),
|
||||
name="whatsapp_account_unlink",
|
||||
),
|
||||
path(
|
||||
"services/instagram/<str:type>/add/",
|
||||
instagram.InstagramAccountAdd.as_view(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<div id="widget">
|
||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
||||
<div class="grid-stack-item-content">
|
||||
<span class="gia-widget-focus-indicator" aria-hidden="true">
|
||||
<i class="{{ widget_icon|default:'fa-solid fa-circle' }}"></i>
|
||||
</span>
|
||||
|
||||
<nav class="panel">
|
||||
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
|
||||
<i class="{{ widget_icon|default:'fa-solid fa-arrows-up-down-left-right' }} has-text-grey-light"></i>
|
||||
{% block close_button %}
|
||||
{% include "mixins/partials/close-widget.html" %}
|
||||
{% endblock %}
|
||||
@@ -30,60 +25,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#widget-{{ unique }} .grid-stack-item-content {
|
||||
position: relative;
|
||||
}
|
||||
#widget-{{ unique }} .gia-widget-focus-indicator {
|
||||
position: absolute;
|
||||
left: 0.32rem;
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
color: #2f5f9f;
|
||||
background: rgba(235, 244, 255, 0.92);
|
||||
border: 1px solid rgba(47, 95, 159, 0.3);
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scale(0.92);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
#widget-{{ unique }}.is-widget-active .gia-widget-focus-indicator {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
{% block custom_script %}
|
||||
{% endblock %}
|
||||
var widget_event = new Event("load-widget");
|
||||
document.dispatchEvent(widget_event);
|
||||
(function () {
|
||||
var widgetRoot = document.getElementById("widget-{{ unique }}");
|
||||
if (!widgetRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setActive() {
|
||||
document.querySelectorAll(".grid-stack-item.is-widget-active").forEach(function (node) {
|
||||
if (node !== widgetRoot) {
|
||||
node.classList.remove("is-widget-active");
|
||||
}
|
||||
});
|
||||
widgetRoot.classList.add("is-widget-active");
|
||||
}
|
||||
|
||||
widgetRoot.addEventListener("mousedown", setActive);
|
||||
widgetRoot.addEventListener("focusin", setActive);
|
||||
window.setTimeout(setActive, 0);
|
||||
})();
|
||||
</script>
|
||||
{% block custom_end %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -83,9 +83,11 @@
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact</th>
|
||||
<th>Person</th>
|
||||
<th>Detected Name</th>
|
||||
<th>Service</th>
|
||||
<th>Identifier</th>
|
||||
<th>Suggested Match</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -93,12 +95,35 @@
|
||||
<tbody>
|
||||
{% for row in candidates %}
|
||||
<tr>
|
||||
<td>{{ row.person_name }}</td>
|
||||
<td>{{ row.linked_person_name|default:"-" }}</td>
|
||||
<td>{{ row.detected_name|default:"-" }}</td>
|
||||
<td>
|
||||
<span class="icon is-small"><i class="{{ row.service_icon_class }}"></i></span>
|
||||
{{ row.service|title }}
|
||||
</td>
|
||||
<td><code>{{ row.identifier }}</code></td>
|
||||
<td>
|
||||
{% if not row.linked_person and row.suggestions %}
|
||||
<div class="buttons are-small">
|
||||
{% for suggestion in row.suggestions %}
|
||||
<form method="post" style="display: inline-flex;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="service" value="{{ row.service }}">
|
||||
<input type="hidden" name="identifier" value="{{ row.identifier }}">
|
||||
<input type="hidden" name="person_id" value="{{ suggestion.person.id }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="button is-small is-success is-light is-rounded"
|
||||
title="Accept suggested match">
|
||||
<span class="icon is-small"><i class="fa-solid fa-check"></i></span>
|
||||
<span>{{ suggestion.person.name }}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="has-text-grey">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.linked_person %}
|
||||
<span class="tag is-success is-light">linked</span>
|
||||
|
||||
@@ -61,11 +61,6 @@
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
">
|
||||
<span
|
||||
class="tag is-dark"
|
||||
style="min-width: 2.5rem; justify-content: center;">
|
||||
<i class="{{ row.service_icon_class|default:manual_icon_class }}" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span
|
||||
class="tag is-white"
|
||||
style="
|
||||
@@ -76,8 +71,7 @@
|
||||
gap: 0.75rem;
|
||||
padding-left: 0.7rem;
|
||||
padding-right: 0.7rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
min-width: 0;
|
||||
">
|
||||
<span
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{% if items %}
|
||||
{% for item in items %}
|
||||
<a class="navbar-item" href="{{ item.compose_url }}">
|
||||
<span class="icon is-small"><i class="{{ item.service_icon_class|default:manual_icon_class }}"></i></span>
|
||||
<span style="margin-left: 0.35rem;">
|
||||
<span>
|
||||
{{ item.person_name }} · {{ item.service|title }}
|
||||
{% if not item.linked_person %}
|
||||
<small class="has-text-grey"> · unlinked</small>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% load cache %}
|
||||
{% include 'mixins/partials/notify.html' %}
|
||||
{% cache 600 objects_signal_accounts request.user.id object_list type service %}
|
||||
<div id="{{ context_object_name|slugify }}-panel">
|
||||
{% if service_warning %}
|
||||
<article class="notification is-warning is-light" style="margin-bottom: 0.55rem;">
|
||||
{{ service_warning }}
|
||||
@@ -8,10 +7,10 @@
|
||||
{% endif %}
|
||||
<table
|
||||
class="table is-fullwidth is-hoverable"
|
||||
hx-target="#{{ context_object_name }}-table"
|
||||
id="{{ context_object_name }}-table"
|
||||
hx-target="#{{ context_object_name|slugify }}-panel"
|
||||
id="{{ context_object_name|slugify }}-table"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="{{ context_object_name_singular }}Event from:body"
|
||||
hx-trigger="{{ context_object_name_singular|slugify }}-event from:body"
|
||||
hx-get="{{ list_url }}">
|
||||
<thead>
|
||||
<th>{{ service_label|default:"Service" }} account</th>
|
||||
@@ -24,12 +23,17 @@
|
||||
<div class="buttons">
|
||||
<button
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-delete="{# url 'account_delete' type=type pk=item.id #}"
|
||||
{% if account_unlink_url_name %}
|
||||
hx-delete="{% url account_unlink_url_name type=type account=item %}"
|
||||
hx-trigger="click"
|
||||
hx-target="#modals-here"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#{{ context_object_name|slugify }}-panel"
|
||||
hx-swap="outerHTML"
|
||||
{% endif %}
|
||||
{% if account_unlink_url_name %}
|
||||
hx-confirm="Are you sure you wish to unlink {{ item }}?"
|
||||
class="button">
|
||||
{% endif %}
|
||||
class="button"
|
||||
{% if not account_unlink_url_name %}disabled{% endif %}>
|
||||
<span class="icon-text">
|
||||
<span class="icon">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
@@ -94,8 +98,8 @@
|
||||
<form
|
||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||
hx-post="{% url account_add_url_name type=type %}"
|
||||
hx-target="#widgets-here"
|
||||
hx-swap="innerHTML">
|
||||
hx-target="{% if account_add_target %}{{ account_add_target }}{% else %}#widgets-here{% endif %}"
|
||||
hx-swap="{% if account_add_swap %}{{ account_add_swap }}{% else %}innerHTML{% endif %}">
|
||||
{% csrf_token %}
|
||||
<div class="field has-addons">
|
||||
<div id="device" class="control is-expanded has-icons-left">
|
||||
@@ -122,4 +126,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endcache %}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<div class="whatsapp-account-add-fragment">
|
||||
{% if object.ok %}
|
||||
<img src="data:image/png;base64, {{ object.image_b64 }}" alt="WhatsApp QR code" />
|
||||
<img
|
||||
src="data:image/png;base64, {{ object.image_b64 }}"
|
||||
alt="WhatsApp QR code"
|
||||
style="
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
" />
|
||||
{% if object.warning %}
|
||||
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
|
||||
{% endif %}
|
||||
@@ -27,8 +36,15 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if object.debug_lines %}
|
||||
<details style="margin-top: 0.6rem;">
|
||||
<details open style="margin-top: 0.6rem;">
|
||||
<summary><strong>Runtime Debug</strong></summary>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-light"
|
||||
style="margin-top: 0.45rem; margin-bottom: 0.35rem;"
|
||||
onclick="navigator.clipboard.writeText(this.nextElementSibling.innerText); return false;">
|
||||
Copy debug
|
||||
</button>
|
||||
<article class="notification is-light" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||
<pre class="is-size-7" style="white-space: pre-wrap; margin: 0;">{% for line in object.debug_lines %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from difflib import SequenceMatcher
|
||||
from datetime import datetime, timezone as dt_timezone
|
||||
from urllib.parse import quote_plus, urlencode, urlparse
|
||||
|
||||
@@ -1357,7 +1358,29 @@ def _manual_contact_rows(user):
|
||||
.order_by("person__name", "service", "identifier")
|
||||
)
|
||||
|
||||
def add_row(*, service, identifier, person=None, source="linked", account=""):
|
||||
def _normalize_contact_key(value: str) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if "@" in raw:
|
||||
raw = raw.split("@", 1)[0]
|
||||
return raw
|
||||
|
||||
def _clean_detected_name(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if text in {"~", "-", "_"}:
|
||||
return ""
|
||||
return text
|
||||
|
||||
def add_row(
|
||||
*,
|
||||
service,
|
||||
identifier,
|
||||
person=None,
|
||||
source="linked",
|
||||
account="",
|
||||
detected_name="",
|
||||
):
|
||||
service_key = _default_service(service)
|
||||
identifier_value = str(identifier or "").strip()
|
||||
if not identifier_value:
|
||||
@@ -1367,12 +1390,14 @@ def _manual_contact_rows(user):
|
||||
return
|
||||
seen.add(key)
|
||||
urls = _compose_urls(service_key, identifier_value, person.id if person else None)
|
||||
person_name = person.name if person else ""
|
||||
if not person_name:
|
||||
person_name = str(account or identifier_value)
|
||||
linked_person_name = person.name if person else ""
|
||||
detected = _clean_detected_name(detected_name or account or "")
|
||||
person_name = linked_person_name or detected or identifier_value
|
||||
rows.append(
|
||||
{
|
||||
"person_name": person_name,
|
||||
"linked_person_name": linked_person_name,
|
||||
"detected_name": detected,
|
||||
"service": service_key,
|
||||
"service_icon_class": _service_icon_class(service_key),
|
||||
"identifier": identifier_value,
|
||||
@@ -1405,19 +1430,24 @@ def _manual_contact_rows(user):
|
||||
}
|
||||
signal_chats = Chat.objects.all().order_by("-id")[:500]
|
||||
for chat in signal_chats:
|
||||
for candidate in (
|
||||
str(chat.source_uuid or "").strip(),
|
||||
str(chat.source_number or "").strip(),
|
||||
):
|
||||
uuid_candidate = str(chat.source_uuid or "").strip()
|
||||
number_candidate = str(chat.source_number or "").strip()
|
||||
fallback_linked = None
|
||||
if uuid_candidate:
|
||||
fallback_linked = signal_links.get(uuid_candidate)
|
||||
if fallback_linked is None and number_candidate:
|
||||
fallback_linked = signal_links.get(number_candidate)
|
||||
for candidate in (uuid_candidate, number_candidate):
|
||||
if not candidate:
|
||||
continue
|
||||
linked = signal_links.get(candidate)
|
||||
linked = signal_links.get(candidate) or fallback_linked
|
||||
add_row(
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
person=(linked.person if linked else None),
|
||||
source="signal_chat",
|
||||
account=str(chat.account or ""),
|
||||
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
|
||||
)
|
||||
|
||||
whatsapp_links = {
|
||||
@@ -1429,6 +1459,12 @@ def _manual_contact_rows(user):
|
||||
)
|
||||
}
|
||||
wa_contacts = transport.get_runtime_state("whatsapp").get("contacts") or []
|
||||
wa_accounts = transport.get_runtime_state("whatsapp").get("accounts") or []
|
||||
wa_account_keys = {
|
||||
_normalize_contact_key(value)
|
||||
for value in wa_accounts
|
||||
if str(value or "").strip()
|
||||
}
|
||||
if isinstance(wa_contacts, list):
|
||||
for item in wa_contacts:
|
||||
if not isinstance(item, dict):
|
||||
@@ -1436,19 +1472,69 @@ def _manual_contact_rows(user):
|
||||
candidate = str(item.get("identifier") or item.get("jid") or "").strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if _normalize_contact_key(candidate) in wa_account_keys:
|
||||
continue
|
||||
detected_name = _clean_detected_name(item.get("name") or item.get("chat") or "")
|
||||
if detected_name.lower() == "linked account":
|
||||
continue
|
||||
linked = whatsapp_links.get(candidate)
|
||||
if linked is None and "@" in candidate:
|
||||
linked = whatsapp_links.get(candidate.split("@", 1)[0])
|
||||
add_row(
|
||||
service="whatsapp",
|
||||
identifier=candidate,
|
||||
person=(linked.person if linked else None),
|
||||
source="whatsapp_runtime",
|
||||
account=str(item.get("name") or item.get("chat") or ""),
|
||||
account=detected_name,
|
||||
detected_name=detected_name,
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
|
||||
return rows
|
||||
|
||||
|
||||
def _name_for_match(value: str) -> str:
|
||||
lowered = re.sub(r"[^a-z0-9]+", " ", str(value or "").strip().lower())
|
||||
return re.sub(r"\s+", " ", lowered).strip()
|
||||
|
||||
|
||||
def _suggest_people_for_candidate(candidate: dict, people: list[Person]) -> list[dict]:
|
||||
if not people:
|
||||
return []
|
||||
base_name = str(candidate.get("detected_name") or "").strip()
|
||||
if not base_name:
|
||||
return []
|
||||
base_norm = _name_for_match(base_name)
|
||||
if not base_norm:
|
||||
return []
|
||||
|
||||
scored = []
|
||||
base_tokens = {token for token in base_norm.split(" ") if token}
|
||||
for person in people:
|
||||
person_norm = _name_for_match(person.name)
|
||||
if not person_norm:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, base_norm, person_norm).ratio()
|
||||
person_tokens = {token for token in person_norm.split(" ") if token}
|
||||
overlap = 0.0
|
||||
if base_tokens and person_tokens:
|
||||
overlap = len(base_tokens & person_tokens) / max(
|
||||
len(base_tokens), len(person_tokens)
|
||||
)
|
||||
score = max(ratio, overlap)
|
||||
if score < 0.62:
|
||||
continue
|
||||
scored.append(
|
||||
{
|
||||
"person": person,
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
|
||||
scored.sort(key=lambda item: item["score"], reverse=True)
|
||||
return scored[:3]
|
||||
|
||||
|
||||
def _load_messages(user, person_identifier, limit):
|
||||
if person_identifier is None:
|
||||
return {"session": None, "messages": []}
|
||||
@@ -1646,12 +1732,18 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
]
|
||||
|
||||
def _context(self, request, notice="", level="info"):
|
||||
people = (
|
||||
people_qs = (
|
||||
Person.objects.filter(user=request.user)
|
||||
.prefetch_related("personidentifier_set")
|
||||
.order_by("name")
|
||||
)
|
||||
people = list(people_qs)
|
||||
candidates = _manual_contact_rows(request.user)
|
||||
for row in candidates:
|
||||
row["suggestions"] = []
|
||||
if row.get("linked_person"):
|
||||
continue
|
||||
row["suggestions"] = _suggest_people_for_candidate(row, people)
|
||||
return {
|
||||
"people": people,
|
||||
"candidates": candidates,
|
||||
|
||||
@@ -5,11 +5,14 @@ from django.views import View
|
||||
from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import PersonIdentifier
|
||||
from core.models import ChatSession, Message, PersonIdentifier
|
||||
from core.views.compose import _compose_urls, _service_icon_class
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
from core.util import logs
|
||||
import time
|
||||
|
||||
log = logs.get_logger("whatsapp_view")
|
||||
|
||||
|
||||
class WhatsApp(SuperUserRequiredMixin, View):
|
||||
template_name = "pages/signal.html"
|
||||
@@ -28,6 +31,54 @@ class WhatsApp(SuperUserRequiredMixin, View):
|
||||
},
|
||||
)
|
||||
|
||||
def delete(self, request, *args, **kwargs):
|
||||
account = (
|
||||
str(request.GET.get("account") or "").strip()
|
||||
or next(
|
||||
(
|
||||
str(item or "").strip()
|
||||
for item in transport.list_accounts("whatsapp")
|
||||
if str(item or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
)
|
||||
if account:
|
||||
transport.unlink_account("whatsapp", account)
|
||||
if not request.htmx:
|
||||
return self.get(request)
|
||||
current_url = str(request.headers.get("HX-Current-URL") or "")
|
||||
list_type = "widget" if "/widget/" in current_url else "page"
|
||||
rows = []
|
||||
for item in transport.list_accounts("whatsapp"):
|
||||
if isinstance(item, dict):
|
||||
value = (
|
||||
item.get("number")
|
||||
or item.get("id")
|
||||
or item.get("jid")
|
||||
or item.get("account")
|
||||
)
|
||||
if value:
|
||||
rows.append(str(value))
|
||||
elif item:
|
||||
rows.append(str(item))
|
||||
context = {
|
||||
"service": "whatsapp",
|
||||
"service_label": "WhatsApp",
|
||||
"account_add_url_name": "whatsapp_account_add",
|
||||
"account_unlink_url_name": "whatsapp_account_unlink",
|
||||
"show_contact_actions": True,
|
||||
"contacts_url_name": "whatsapp_contacts",
|
||||
"chats_url_name": "whatsapp_chats",
|
||||
"service_warning": transport.get_service_warning("whatsapp"),
|
||||
"object_list": rows,
|
||||
"list_url": reverse("whatsapp_accounts", kwargs={"type": list_type}),
|
||||
"type": list_type,
|
||||
"context_object_name_singular": "WhatsApp Account",
|
||||
"context_object_name": "WhatsApp Accounts",
|
||||
}
|
||||
return render(request, "partials/signal-accounts.html", context)
|
||||
|
||||
|
||||
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
list_template = "partials/signal-accounts.html"
|
||||
@@ -59,6 +110,7 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
"service": "whatsapp",
|
||||
"service_label": "WhatsApp",
|
||||
"account_add_url_name": "whatsapp_account_add",
|
||||
"account_unlink_url_name": "whatsapp_account_unlink",
|
||||
"show_contact_actions": True,
|
||||
"contacts_url_name": "whatsapp_contacts",
|
||||
"chats_url_name": "whatsapp_chats",
|
||||
@@ -67,6 +119,43 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
|
||||
return self._normalize_accounts(transport.list_accounts("whatsapp"))
|
||||
|
||||
|
||||
class WhatsAppAccountUnlink(SuperUserRequiredMixin, View):
|
||||
def delete(self, request, *args, **kwargs):
|
||||
account = str(kwargs.get("account") or "").strip()
|
||||
_ = transport.unlink_account("whatsapp", account)
|
||||
|
||||
rows = []
|
||||
for item in transport.list_accounts("whatsapp"):
|
||||
if isinstance(item, dict):
|
||||
value = (
|
||||
item.get("number")
|
||||
or item.get("id")
|
||||
or item.get("jid")
|
||||
or item.get("account")
|
||||
)
|
||||
if value:
|
||||
rows.append(str(value))
|
||||
elif item:
|
||||
rows.append(str(item))
|
||||
|
||||
context = {
|
||||
"service": "whatsapp",
|
||||
"service_label": "WhatsApp",
|
||||
"account_add_url_name": "whatsapp_account_add",
|
||||
"account_unlink_url_name": "whatsapp_account_unlink",
|
||||
"show_contact_actions": True,
|
||||
"contacts_url_name": "whatsapp_contacts",
|
||||
"chats_url_name": "whatsapp_chats",
|
||||
"service_warning": transport.get_service_warning("whatsapp"),
|
||||
"object_list": rows,
|
||||
"list_url": reverse("whatsapp_accounts", kwargs={"type": kwargs["type"]}),
|
||||
"type": kwargs["type"],
|
||||
"context_object_name_singular": "WhatsApp Account",
|
||||
"context_object_name": "WhatsApp Accounts",
|
||||
}
|
||||
return render(request, "partials/signal-accounts.html", context)
|
||||
|
||||
|
||||
class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
list_template = "partials/whatsapp-contacts-list.html"
|
||||
|
||||
@@ -76,6 +165,26 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
list_url_name = "whatsapp_contacts"
|
||||
list_url_args = ["type", "pk"]
|
||||
|
||||
def _linked_identifier(self, identifier: str, jid: str):
|
||||
candidates = [str(identifier or "").strip(), str(jid or "").strip()]
|
||||
if candidates[1] and "@" in candidates[1]:
|
||||
candidates.append(candidates[1].split("@", 1)[0])
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
linked = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=self.request.user,
|
||||
service="whatsapp",
|
||||
identifier=candidate,
|
||||
)
|
||||
.select_related("person")
|
||||
.first()
|
||||
)
|
||||
if linked:
|
||||
return linked
|
||||
return None
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
state = transport.get_runtime_state("whatsapp")
|
||||
runtime_contacts = state.get("contacts") or []
|
||||
@@ -90,15 +199,8 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
linked = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=self.request.user,
|
||||
service="whatsapp",
|
||||
identifier=identifier,
|
||||
)
|
||||
.select_related("person")
|
||||
.first()
|
||||
)
|
||||
jid = str(item.get("jid") or "").strip()
|
||||
linked = self._linked_identifier(identifier, jid)
|
||||
urls = _compose_urls(
|
||||
"whatsapp",
|
||||
identifier,
|
||||
@@ -107,7 +209,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": str(item.get("jid") or ""),
|
||||
"jid": jid,
|
||||
"name": str(item.get("name") or item.get("chat") or ""),
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": linked.person.name if linked else "",
|
||||
@@ -159,10 +261,61 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
context_object_name = "WhatsApp Chats"
|
||||
list_url_name = "whatsapp_chats"
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
rows = []
|
||||
sessions = (
|
||||
ChatSession.objects.filter(
|
||||
user=self.request.user,
|
||||
identifier__service="whatsapp",
|
||||
)
|
||||
.select_related("identifier", "identifier__person")
|
||||
.order_by("-last_interaction", "-id")
|
||||
)
|
||||
for session in sessions:
|
||||
identifier = str(session.identifier.identifier or "").strip()
|
||||
if not identifier:
|
||||
continue
|
||||
latest = (
|
||||
Message.objects.filter(user=self.request.user, session=session)
|
||||
.order_by("-ts")
|
||||
.first()
|
||||
)
|
||||
urls = _compose_urls("whatsapp", identifier, session.identifier.person_id)
|
||||
preview = str((latest.text if latest else "") or "").strip()
|
||||
if len(preview) > 80:
|
||||
preview = f"{preview[:77]}..."
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"jid": identifier,
|
||||
"name": (
|
||||
preview
|
||||
or session.identifier.person.name
|
||||
or "WhatsApp Chat"
|
||||
),
|
||||
"service_icon_class": _service_icon_class("whatsapp"),
|
||||
"person_name": session.identifier.person.name,
|
||||
"compose_page_url": urls["page_url"],
|
||||
"compose_widget_url": urls["widget_url"],
|
||||
"match_url": (
|
||||
f"{reverse('compose_contact_match')}?"
|
||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||
),
|
||||
"last_ts": int(latest.ts or 0) if latest else 0,
|
||||
}
|
||||
)
|
||||
if rows:
|
||||
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
|
||||
return rows
|
||||
return super().get_queryset(*args, **kwargs)
|
||||
|
||||
|
||||
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
detail_template = "partials/whatsapp-account-add.html"
|
||||
service = "whatsapp"
|
||||
extra_context = {
|
||||
"widget_options": 'gs-w="6" gs-h="13" gs-x="0" gs-y="0" gs-min-w="4"',
|
||||
}
|
||||
context_object_name_singular = "Add Account"
|
||||
context_object_name = "Add Account"
|
||||
detail_url_name = "whatsapp_account_add"
|
||||
@@ -201,6 +354,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
return f"{max(0, now - value)}s ago"
|
||||
|
||||
qr_value = str(state.get("pair_qr") or "")
|
||||
contacts = state.get("contacts") or []
|
||||
return [
|
||||
f"connected={bool(state.get('connected'))}",
|
||||
f"runtime_seen={_age('runtime_seen_at')}",
|
||||
@@ -212,6 +366,10 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
f"qr_handler_registered={state.get('qr_handler_registered')}",
|
||||
f"last_event={state.get('last_event') or '-'}",
|
||||
f"last_error={state.get('last_error') or '-'}",
|
||||
f"contacts_source={state.get('contacts_source') or '-'}",
|
||||
f"contacts_count={len(contacts) if isinstance(contacts, list) else 0}",
|
||||
f"contacts_sync_count={state.get('contacts_sync_count') or 0}",
|
||||
f"contacts_synced={_age('contacts_synced_at')}",
|
||||
f"pair_qr_present={bool(qr_value)}",
|
||||
f"session_db={state.get('session_db') or '-'}",
|
||||
]
|
||||
@@ -231,21 +389,28 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
device_name = self._device_name()
|
||||
if not self._refresh_only():
|
||||
transport.request_pairing(self.service, device_name)
|
||||
debug_lines = self._debug_state()
|
||||
log.info(
|
||||
"whatsapp add-account runtime debug [%s]: %s",
|
||||
("refresh" if self._refresh_only() else "request"),
|
||||
" | ".join(debug_lines),
|
||||
)
|
||||
try:
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
return {
|
||||
"ok": True,
|
||||
"image_b64": transport.image_bytes_to_base64(image_bytes),
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
"debug_lines": self._debug_state(),
|
||||
"debug_lines": debug_lines,
|
||||
}
|
||||
except Exception as exc:
|
||||
error_text = str(exc)
|
||||
log.warning("whatsapp add-account get_link_qr failed: %s", error_text)
|
||||
return {
|
||||
"ok": False,
|
||||
"pending": "pairing qr" in error_text.lower(),
|
||||
"device": device_name,
|
||||
"error": error_text,
|
||||
"warning": transport.get_service_warning(self.service),
|
||||
"debug_lines": self._debug_state(),
|
||||
"debug_lines": debug_lines,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
@@ -31,6 +32,7 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
@@ -86,6 +88,7 @@ services:
|
||||
- ${REPO_DIR}:/code
|
||||
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
|
||||
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
|
||||
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
|
||||
- type: bind
|
||||
source: /code/vrun
|
||||
target: /var/run
|
||||
@@ -105,6 +108,7 @@ services:
|
||||
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
|
||||
OPERATION: "${OPERATION}"
|
||||
SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
|
||||
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
|
||||
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
|
||||
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
|
||||
XMPP_ADDRESS: "${XMPP_ADDRESS}"
|
||||
@@ -286,3 +290,4 @@ services:
|
||||
|
||||
volumes:
|
||||
gia_redis_data: {}
|
||||
gia_whatsapp_data: {}
|
||||
|
||||
Reference in New Issue
Block a user