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(), whatsapp.WhatsAppAccountAdd.as_view(),
name="whatsapp_account_add", name="whatsapp_account_add",
), ),
path(
"services/whatsapp/<str:type>/unlink/<path:account>/",
whatsapp.WhatsAppAccountUnlink.as_view(),
name="whatsapp_account_unlink",
),
path( path(
"services/instagram/<str:type>/add/", "services/instagram/<str:type>/add/",
instagram.InstagramAccountAdd.as_view(), instagram.InstagramAccountAdd.as_view(),

View File

@@ -3,6 +3,7 @@ import base64
import io import io
import secrets import secrets
import time import time
from urllib.parse import quote_plus
from typing import Any from typing import Any
import aiohttp import aiohttp
@@ -99,11 +100,94 @@ def list_accounts(service: str):
state = get_runtime_state(service_key) state = get_runtime_state(service_key)
accounts = state.get("accounts") or [] 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): if isinstance(accounts, list):
return accounts return accounts
return [] 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: def get_service_warning(service: str) -> str:
service_key = _service_key(service) service_key = _service_key(service)
if service_key == "signal": if service_key == "signal":
@@ -131,6 +215,18 @@ def request_pairing(service: str, device_name: str = ""):
service_key = _service_key(service) service_key = _service_key(service)
if service_key not in {"whatsapp", "instagram"}: if service_key not in {"whatsapp", "instagram"}:
return 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" device = str(device_name or "GIA Device").strip() or "GIA Device"
update_runtime_state( update_runtime_state(
service_key, service_key,
@@ -454,6 +550,17 @@ def get_link_qr(service: str, device_name: str):
return cached return cached
if service_key == "whatsapp": 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( raise RuntimeError(
"Neonize has not provided a pairing QR yet. " "Neonize has not provided a pairing QR yet. "
"Ensure UR is running with WHATSAPP_ENABLED=true and retry." "Ensure UR is running with WHATSAPP_ENABLED=true and retry."

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import os import os
import re import re
import sqlite3
import time import time
from urllib.parse import quote_plus from urllib.parse import quote_plus
@@ -51,9 +52,21 @@ class WhatsAppClient(ClientBase):
getattr(settings, "WHATSAPP_DATABASE_URL", "") getattr(settings, "WHATSAPP_DATABASE_URL", "")
).strip() ).strip()
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.client_name) or "gia_whatsapp" 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) 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( self._publish_state(
connected=False, connected=False,
warning=( warning=(
@@ -61,7 +74,7 @@ class WhatsAppClient(ClientBase):
if not self.enabled if not self.enabled
else "" else ""
), ),
accounts=[], accounts=prior_accounts,
last_event="init", last_event="init",
session_db=self.session_db, session_db=self.session_db,
) )
@@ -171,11 +184,15 @@ class WhatsAppClient(ClientBase):
# Keep task alive so state/callbacks remain active. # Keep task alive so state/callbacks remain active.
next_heartbeat_at = 0.0 next_heartbeat_at = 0.0
next_contacts_sync_at = 0.0
while not self._stopping: while not self._stopping:
now = time.time() now = time.time()
if now >= next_heartbeat_at: if now >= next_heartbeat_at:
self._publish_state(runtime_seen_at=int(now)) self._publish_state(runtime_seen_at=int(now))
next_heartbeat_at = now + 5.0 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() self._mark_qr_wait_timeout()
await self._sync_pair_request() await self._sync_pair_request()
await self._probe_pending_qr(now) await self._probe_pending_qr(now)
@@ -279,6 +296,9 @@ class WhatsAppClient(ClientBase):
last_error=str(exc), last_error=str(exc),
) )
if self._connected:
await self._sync_contacts_from_client()
# Neonize does not always emit QR callbacks after reconnect. Try explicit # Neonize does not always emit QR callbacks after reconnect. Try explicit
# QR-link fetch when available to surface pair data to the UI. # QR-link fetch when available to surface pair data to the UI.
try: try:
@@ -427,6 +447,8 @@ class WhatsAppClient(ClientBase):
presence_ev = getattr(wa_events, "PresenceEv", None) presence_ev = getattr(wa_events, "PresenceEv", None)
pair_ev = getattr(wa_events, "PairStatusEv", None) pair_ev = getattr(wa_events, "PairStatusEv", None)
qr_ev = getattr(wa_events, "QREv", None) 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() self._register_qr_handler()
support = { support = {
@@ -435,6 +457,8 @@ class WhatsAppClient(ClientBase):
"qr_ev": bool(qr_ev), "qr_ev": bool(qr_ev),
"message_ev": bool(message_ev), "message_ev": bool(message_ev),
"receipt_ev": bool(receipt_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( self._publish_state(
event_hook_callable=bool(getattr(self._client, "event", None)), event_hook_callable=bool(getattr(self._client, "event", None)),
@@ -447,12 +471,6 @@ class WhatsAppClient(ClientBase):
async def on_connected(client, event: connected_ev): async def on_connected(client, event: connected_ev):
self._connected = True self._connected = True
account = await self._resolve_account_identifier() account = await self._resolve_account_identifier()
if account:
self._remember_contact(
account,
jid=account,
name="Linked Account",
)
self._publish_state( self._publish_state(
connected=True, connected=True,
warning="", warning="",
@@ -463,6 +481,7 @@ class WhatsAppClient(ClientBase):
connected_at=int(time.time()), connected_at=int(time.time()),
last_error="", last_error="",
) )
await self._sync_contacts_from_client()
self._register_event(connected_ev, on_connected) self._register_event(connected_ev, on_connected)
@@ -513,12 +532,6 @@ class WhatsAppClient(ClientBase):
status_text = str(status_raw or "").strip().lower() status_text = str(status_raw or "").strip().lower()
if status_text in {"2", "success"}: if status_text in {"2", "success"}:
account = await self._resolve_account_identifier() account = await self._resolve_account_identifier()
if account:
self._remember_contact(
account,
jid=account,
name="Linked Account",
)
self._connected = True self._connected = True
self._publish_state( self._publish_state(
connected=True, connected=True,
@@ -530,6 +543,7 @@ class WhatsAppClient(ClientBase):
connected_at=int(time.time()), connected_at=int(time.time()),
last_error="", last_error="",
) )
await self._sync_contacts_from_client()
elif status_text in {"1", "error"}: elif status_text in {"1", "error"}:
error_text = str(self._pluck(event, "Error") or "").strip() error_text = str(self._pluck(event, "Error") or "").strip()
self._publish_state( self._publish_state(
@@ -544,6 +558,11 @@ class WhatsAppClient(ClientBase):
if qr_ev is not None: if qr_ev is not None:
async def on_qr_event(client, event: qr_ev): 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) qr_payload = self._extract_pair_qr(event)
if not qr_payload: if not qr_payload:
return return
@@ -561,6 +580,21 @@ class WhatsAppClient(ClientBase):
self._register_event(qr_ev, on_qr_event) 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): def _mark_qr_wait_timeout(self):
state = transport.get_runtime_state(self.service) state = transport.get_runtime_state(self.service)
if str(state.get("pair_status") or "").strip().lower() != "pending": if str(state.get("pair_status") or "").strip().lower() != "pending":
@@ -613,6 +647,15 @@ class WhatsAppClient(ClientBase):
value = self._pluck(me, *path) value = self._pluck(me, *path)
if value: if value:
return str(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 return self.client_name
def _pluck(self, obj, *path): def _pluck(self, obj, *path):
@@ -657,8 +700,283 @@ class WhatsAppClient(ClientBase):
out.add(f"+{digits}") out.add(f"+{digits}")
return out 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=""): def _remember_contact(self, identifier, *, jid="", name="", chat=""):
cleaned = str(identifier or "").strip() cleaned = str(identifier or "").strip()
if "@" in cleaned:
cleaned = cleaned.split("@", 1)[0]
if not cleaned: if not cleaned:
return return
state = transport.get_runtime_state(self.service) state = transport.get_runtime_state(self.service)

View File

@@ -1,13 +1,8 @@
<div id="widget"> <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 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"> <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"> <nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;"> <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 %} {% block close_button %}
{% include "mixins/partials/close-widget.html" %} {% include "mixins/partials/close-widget.html" %}
{% endblock %} {% endblock %}
@@ -30,60 +25,11 @@
</div> </div>
</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> <script>
{% block custom_script %} {% block custom_script %}
{% endblock %} {% endblock %}
var widget_event = new Event("load-widget"); var widget_event = new Event("load-widget");
document.dispatchEvent(widget_event); 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> </script>
{% block custom_end %} {% block custom_end %}
{% endblock %} {% endblock %}

View File

@@ -83,9 +83,11 @@
<table class="table is-fullwidth is-hoverable is-striped"> <table class="table is-fullwidth is-hoverable is-striped">
<thead> <thead>
<tr> <tr>
<th>Contact</th> <th>Person</th>
<th>Detected Name</th>
<th>Service</th> <th>Service</th>
<th>Identifier</th> <th>Identifier</th>
<th>Suggested Match</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
</tr> </tr>
@@ -93,12 +95,35 @@
<tbody> <tbody>
{% for row in candidates %} {% for row in candidates %}
<tr> <tr>
<td>{{ row.person_name }}</td> <td>{{ row.linked_person_name|default:"-" }}</td>
<td>{{ row.detected_name|default:"-" }}</td>
<td> <td>
<span class="icon is-small"><i class="{{ row.service_icon_class }}"></i></span>
{{ row.service|title }} {{ row.service|title }}
</td> </td>
<td><code>{{ row.identifier }}</code></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> <td>
{% if row.linked_person %} {% if row.linked_person %}
<span class="tag is-success is-light">linked</span> <span class="tag is-success is-light">linked</span>

View File

@@ -61,11 +61,6 @@
margin: 0; margin: 0;
white-space: nowrap; 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 <span
class="tag is-white" class="tag is-white"
style=" style="
@@ -76,8 +71,7 @@
gap: 0.75rem; gap: 0.75rem;
padding-left: 0.7rem; padding-left: 0.7rem;
padding-right: 0.7rem; padding-right: 0.7rem;
border-top: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
min-width: 0; min-width: 0;
"> ">
<span <span

View File

@@ -1,8 +1,7 @@
{% if items %} {% if items %}
{% for item in items %} {% for item in items %}
<a class="navbar-item" href="{{ item.compose_url }}"> <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>
<span style="margin-left: 0.35rem;">
{{ item.person_name }} · {{ item.service|title }} {{ item.person_name }} · {{ item.service|title }}
{% if not item.linked_person %} {% if not item.linked_person %}
<small class="has-text-grey"> · unlinked</small> <small class="has-text-grey"> · unlinked</small>

View File

@@ -1,6 +1,5 @@
{% load cache %}
{% include 'mixins/partials/notify.html' %} {% 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 %} {% if service_warning %}
<article class="notification is-warning is-light" style="margin-bottom: 0.55rem;"> <article class="notification is-warning is-light" style="margin-bottom: 0.55rem;">
{{ service_warning }} {{ service_warning }}
@@ -8,10 +7,10 @@
{% endif %} {% endif %}
<table <table
class="table is-fullwidth is-hoverable" class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table" hx-target="#{{ context_object_name|slugify }}-panel"
id="{{ context_object_name }}-table" id="{{ context_object_name|slugify }}-table"
hx-swap="outerHTML" 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 }}"> hx-get="{{ list_url }}">
<thead> <thead>
<th>{{ service_label|default:"Service" }} account</th> <th>{{ service_label|default:"Service" }} account</th>
@@ -24,12 +23,17 @@
<div class="buttons"> <div class="buttons">
<button <button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{# url 'account_delete' type=type pk=item.id #}" {% if account_unlink_url_name %}
hx-trigger="click" hx-delete="{% url account_unlink_url_name type=type account=item %}"
hx-target="#modals-here" hx-trigger="click"
hx-swap="innerHTML" hx-target="#{{ context_object_name|slugify }}-panel"
hx-confirm="Are you sure you wish to unlink {{ item }}?" hx-swap="outerHTML"
class="button"> {% endif %}
{% if account_unlink_url_name %}
hx-confirm="Are you sure you wish to unlink {{ item }}?"
{% endif %}
class="button"
{% if not account_unlink_url_name %}disabled{% endif %}>
<span class="icon-text"> <span class="icon-text">
<span class="icon"> <span class="icon">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
@@ -94,8 +98,8 @@
<form <form
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-post="{% url account_add_url_name type=type %}" hx-post="{% url account_add_url_name type=type %}"
hx-target="#widgets-here" hx-target="{% if account_add_target %}{{ account_add_target }}{% else %}#widgets-here{% endif %}"
hx-swap="innerHTML"> hx-swap="{% if account_add_swap %}{{ account_add_swap }}{% else %}innerHTML{% endif %}">
{% csrf_token %} {% csrf_token %}
<div class="field has-addons"> <div class="field has-addons">
<div id="device" class="control is-expanded has-icons-left"> <div id="device" class="control is-expanded has-icons-left">
@@ -122,4 +126,4 @@
</div> </div>
</div> </div>
</form> </form>
{% endcache %} </div>

View File

@@ -1,6 +1,15 @@
<div class="whatsapp-account-add-fragment"> <div class="whatsapp-account-add-fragment">
{% if object.ok %} {% 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 %} {% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p> <p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %} {% endif %}
@@ -27,8 +36,15 @@
</form> </form>
{% endif %} {% endif %}
{% if object.debug_lines %} {% if object.debug_lines %}
<details style="margin-top: 0.6rem;"> <details open style="margin-top: 0.6rem;">
<summary><strong>Runtime Debug</strong></summary> <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;"> <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 }} <pre class="is-size-7" style="white-space: pre-wrap; margin: 0;">{% for line in object.debug_lines %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>

View File

@@ -4,6 +4,7 @@ import hashlib
import json import json
import re import re
import time import time
from difflib import SequenceMatcher
from datetime import datetime, timezone as dt_timezone from datetime import datetime, timezone as dt_timezone
from urllib.parse import quote_plus, urlencode, urlparse from urllib.parse import quote_plus, urlencode, urlparse
@@ -1357,7 +1358,29 @@ def _manual_contact_rows(user):
.order_by("person__name", "service", "identifier") .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) service_key = _default_service(service)
identifier_value = str(identifier or "").strip() identifier_value = str(identifier or "").strip()
if not identifier_value: if not identifier_value:
@@ -1367,12 +1390,14 @@ def _manual_contact_rows(user):
return return
seen.add(key) seen.add(key)
urls = _compose_urls(service_key, identifier_value, person.id if person else None) urls = _compose_urls(service_key, identifier_value, person.id if person else None)
person_name = person.name if person else "" linked_person_name = person.name if person else ""
if not person_name: detected = _clean_detected_name(detected_name or account or "")
person_name = str(account or identifier_value) person_name = linked_person_name or detected or identifier_value
rows.append( rows.append(
{ {
"person_name": person_name, "person_name": person_name,
"linked_person_name": linked_person_name,
"detected_name": detected,
"service": service_key, "service": service_key,
"service_icon_class": _service_icon_class(service_key), "service_icon_class": _service_icon_class(service_key),
"identifier": identifier_value, "identifier": identifier_value,
@@ -1405,19 +1430,24 @@ def _manual_contact_rows(user):
} }
signal_chats = Chat.objects.all().order_by("-id")[:500] signal_chats = Chat.objects.all().order_by("-id")[:500]
for chat in signal_chats: for chat in signal_chats:
for candidate in ( uuid_candidate = str(chat.source_uuid or "").strip()
str(chat.source_uuid or "").strip(), number_candidate = str(chat.source_number or "").strip()
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: if not candidate:
continue continue
linked = signal_links.get(candidate) linked = signal_links.get(candidate) or fallback_linked
add_row( add_row(
service="signal", service="signal",
identifier=candidate, identifier=candidate,
person=(linked.person if linked else None), person=(linked.person if linked else None),
source="signal_chat", source="signal_chat",
account=str(chat.account or ""), account=str(chat.account or ""),
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
) )
whatsapp_links = { whatsapp_links = {
@@ -1429,6 +1459,12 @@ def _manual_contact_rows(user):
) )
} }
wa_contacts = transport.get_runtime_state("whatsapp").get("contacts") or [] 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): if isinstance(wa_contacts, list):
for item in wa_contacts: for item in wa_contacts:
if not isinstance(item, dict): 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() candidate = str(item.get("identifier") or item.get("jid") or "").strip()
if not candidate: if not candidate:
continue 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) linked = whatsapp_links.get(candidate)
if linked is None and "@" in candidate:
linked = whatsapp_links.get(candidate.split("@", 1)[0])
add_row( add_row(
service="whatsapp", service="whatsapp",
identifier=candidate, identifier=candidate,
person=(linked.person if linked else None), person=(linked.person if linked else None),
source="whatsapp_runtime", 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"])) rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
return rows 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): def _load_messages(user, person_identifier, limit):
if person_identifier is None: if person_identifier is None:
return {"session": None, "messages": []} return {"session": None, "messages": []}
@@ -1646,12 +1732,18 @@ class ComposeContactMatch(LoginRequiredMixin, View):
] ]
def _context(self, request, notice="", level="info"): def _context(self, request, notice="", level="info"):
people = ( people_qs = (
Person.objects.filter(user=request.user) Person.objects.filter(user=request.user)
.prefetch_related("personidentifier_set") .prefetch_related("personidentifier_set")
.order_by("name") .order_by("name")
) )
people = list(people_qs)
candidates = _manual_contact_rows(request.user) 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 { return {
"people": people, "people": people,
"candidates": candidates, "candidates": candidates,

View File

@@ -5,11 +5,14 @@ from django.views import View
from mixins.views import ObjectList, ObjectRead from mixins.views import ObjectList, ObjectRead
from core.clients import transport 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.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin from core.views.manage.permissions import SuperUserRequiredMixin
from core.util import logs
import time import time
log = logs.get_logger("whatsapp_view")
class WhatsApp(SuperUserRequiredMixin, View): class WhatsApp(SuperUserRequiredMixin, View):
template_name = "pages/signal.html" 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): class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
list_template = "partials/signal-accounts.html" list_template = "partials/signal-accounts.html"
@@ -59,6 +110,7 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
"service": "whatsapp", "service": "whatsapp",
"service_label": "WhatsApp", "service_label": "WhatsApp",
"account_add_url_name": "whatsapp_account_add", "account_add_url_name": "whatsapp_account_add",
"account_unlink_url_name": "whatsapp_account_unlink",
"show_contact_actions": True, "show_contact_actions": True,
"contacts_url_name": "whatsapp_contacts", "contacts_url_name": "whatsapp_contacts",
"chats_url_name": "whatsapp_chats", "chats_url_name": "whatsapp_chats",
@@ -67,6 +119,43 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
return self._normalize_accounts(transport.list_accounts("whatsapp")) 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): class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
list_template = "partials/whatsapp-contacts-list.html" list_template = "partials/whatsapp-contacts-list.html"
@@ -76,6 +165,26 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
list_url_name = "whatsapp_contacts" list_url_name = "whatsapp_contacts"
list_url_args = ["type", "pk"] 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): def get_queryset(self, *args, **kwargs):
state = transport.get_runtime_state("whatsapp") state = transport.get_runtime_state("whatsapp")
runtime_contacts = state.get("contacts") or [] runtime_contacts = state.get("contacts") or []
@@ -90,15 +199,8 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
if not identifier or identifier in seen: if not identifier or identifier in seen:
continue continue
seen.add(identifier) seen.add(identifier)
linked = ( jid = str(item.get("jid") or "").strip()
PersonIdentifier.objects.filter( linked = self._linked_identifier(identifier, jid)
user=self.request.user,
service="whatsapp",
identifier=identifier,
)
.select_related("person")
.first()
)
urls = _compose_urls( urls = _compose_urls(
"whatsapp", "whatsapp",
identifier, identifier,
@@ -107,7 +209,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
rows.append( rows.append(
{ {
"identifier": identifier, "identifier": identifier,
"jid": str(item.get("jid") or ""), "jid": jid,
"name": str(item.get("name") or item.get("chat") or ""), "name": str(item.get("name") or item.get("chat") or ""),
"service_icon_class": _service_icon_class("whatsapp"), "service_icon_class": _service_icon_class("whatsapp"),
"person_name": linked.person.name if linked else "", "person_name": linked.person.name if linked else "",
@@ -159,10 +261,61 @@ class WhatsAppChatsList(WhatsAppContactsList):
context_object_name = "WhatsApp Chats" context_object_name = "WhatsApp Chats"
list_url_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): class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_template = "partials/whatsapp-account-add.html" detail_template = "partials/whatsapp-account-add.html"
service = "whatsapp" 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_singular = "Add Account"
context_object_name = "Add Account" context_object_name = "Add Account"
detail_url_name = "whatsapp_account_add" detail_url_name = "whatsapp_account_add"
@@ -201,6 +354,7 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
return f"{max(0, now - value)}s ago" return f"{max(0, now - value)}s ago"
qr_value = str(state.get("pair_qr") or "") qr_value = str(state.get("pair_qr") or "")
contacts = state.get("contacts") or []
return [ return [
f"connected={bool(state.get('connected'))}", f"connected={bool(state.get('connected'))}",
f"runtime_seen={_age('runtime_seen_at')}", 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"qr_handler_registered={state.get('qr_handler_registered')}",
f"last_event={state.get('last_event') or '-'}", f"last_event={state.get('last_event') or '-'}",
f"last_error={state.get('last_error') 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"pair_qr_present={bool(qr_value)}",
f"session_db={state.get('session_db') or '-'}", f"session_db={state.get('session_db') or '-'}",
] ]
@@ -231,21 +389,28 @@ class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
device_name = self._device_name() device_name = self._device_name()
if not self._refresh_only(): if not self._refresh_only():
transport.request_pairing(self.service, device_name) 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: try:
image_bytes = transport.get_link_qr(self.service, device_name) image_bytes = transport.get_link_qr(self.service, device_name)
return { return {
"ok": True, "ok": True,
"image_b64": transport.image_bytes_to_base64(image_bytes), "image_b64": transport.image_bytes_to_base64(image_bytes),
"warning": transport.get_service_warning(self.service), "warning": transport.get_service_warning(self.service),
"debug_lines": self._debug_state(), "debug_lines": debug_lines,
} }
except Exception as exc: except Exception as exc:
error_text = str(exc) error_text = str(exc)
log.warning("whatsapp add-account get_link_qr failed: %s", error_text)
return { return {
"ok": False, "ok": False,
"pending": "pairing qr" in error_text.lower(), "pending": "pairing qr" in error_text.lower(),
"device": device_name, "device": device_name,
"error": error_text, "error": error_text,
"warning": transport.get_service_warning(self.service), "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}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini - ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3 - ${APP_DATABASE_FILE}:/conf/db.sqlite3
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
- type: bind - type: bind
source: /code/vrun source: /code/vrun
target: /var/run target: /var/run
@@ -31,6 +32,7 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_ADDRESS: "${XMPP_ADDRESS}"
@@ -86,6 +88,7 @@ services:
- ${REPO_DIR}:/code - ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini - ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3 - ${APP_DATABASE_FILE}:/conf/db.sqlite3
- gia_whatsapp_data:${WHATSAPP_DB_DIR}
- type: bind - type: bind
source: /code/vrun source: /code/vrun
target: /var/run target: /var/run
@@ -105,6 +108,7 @@ services:
REGISTRATION_OPEN: "${REGISTRATION_OPEN}" REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}" OPERATION: "${OPERATION}"
SIGNAL_NUMBER: "${SIGNAL_NUMBER}" SIGNAL_NUMBER: "${SIGNAL_NUMBER}"
WHATSAPP_DB_DIR: "${WHATSAPP_DB_DIR}"
WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}" WHATSAPP_ENABLED: "${WHATSAPP_ENABLED}"
INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}" INSTAGRAM_ENABLED: "${INSTAGRAM_ENABLED}"
XMPP_ADDRESS: "${XMPP_ADDRESS}" XMPP_ADDRESS: "${XMPP_ADDRESS}"
@@ -286,3 +290,4 @@ services:
volumes: volumes:
gia_redis_data: {} gia_redis_data: {}
gia_whatsapp_data: {}