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

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