Continue implementing WhatsApp

This commit is contained in:
2026-02-16 00:39:16 +00:00
parent b1a53034d5
commit 3d32834ccf
12 changed files with 796 additions and 120 deletions

View File

@@ -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(),

View File

@@ -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."

View File

@@ -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)

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,
}

View File

@@ -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: {}