Implement contact matching

This commit is contained in:
2026-02-15 23:48:32 +00:00
parent 10af1e4d6b
commit b1a53034d5
12 changed files with 667 additions and 46 deletions

View File

@@ -89,11 +89,21 @@ urlpatterns = [
signal.SignalContactsList.as_view(),
name="signal_contacts",
),
path(
"services/whatsapp/<str:type>/contacts/<str:pk>/",
whatsapp.WhatsAppContactsList.as_view(),
name="whatsapp_contacts",
),
path(
"services/signal/<str:type>/chats/<str:pk>/",
signal.SignalChatsList.as_view(),
name="signal_chats",
),
path(
"services/whatsapp/<str:type>/chats/<str:pk>/",
whatsapp.WhatsAppChatsList.as_view(),
name="whatsapp_chats",
),
path(
"services/signal/<str:type>/messages/<str:pk>/<str:chat_id>/",
signal.SignalMessagesList.as_view(),
@@ -179,6 +189,11 @@ urlpatterns = [
compose.ComposeContactsDropdown.as_view(),
name="compose_contacts_dropdown",
),
path(
"compose/contacts/match/",
compose.ComposeContactMatch.as_view(),
name="compose_contact_match",
),
# AIs
path(
"ai/workspace/",

View File

@@ -262,8 +262,13 @@ class WhatsAppClient(ClientBase):
)
if connected_value:
self._connected = True
account = await self._resolve_account_identifier()
account_list = [account] if account else list(self._accounts or [])
if not account_list:
account_list = [self.client_name]
self._publish_state(
connected=True,
accounts=account_list,
pair_status="connected",
last_event="connected_probe",
connected_at=now_ts,
@@ -442,6 +447,12 @@ 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="",
@@ -502,6 +513,12 @@ 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,
@@ -640,6 +657,35 @@ class WhatsAppClient(ClientBase):
out.add(f"+{digits}")
return out
def _remember_contact(self, identifier, *, jid="", name="", chat=""):
cleaned = str(identifier or "").strip()
if not cleaned:
return
state = transport.get_runtime_state(self.service)
existing = state.get("contacts") or []
rows = [item for item in existing if isinstance(item, dict)]
merged = []
seen = set()
now_ts = int(time.time())
row = {
"identifier": cleaned,
"jid": str(jid or "").strip(),
"name": str(name or "").strip(),
"chat": str(chat or "").strip(),
"seen_at": now_ts,
}
merged.append(row)
seen.add(cleaned)
for item in rows:
candidate = str(item.get("identifier") or item.get("jid") or "").strip()
if not candidate or candidate in seen:
continue
seen.add(candidate)
merged.append(item)
if len(merged) >= 500:
break
self._publish_state(contacts=merged, last_contact_seen_at=now_ts)
def _jid_to_identifier(self, value):
raw = str(value or "").strip()
if not raw:
@@ -761,6 +807,11 @@ class WhatsAppClient(ClientBase):
or self._pluck(event, "timestamp")
)
ts = self._normalize_timestamp(raw_ts)
self._remember_contact(
sender or chat,
jid=sender,
chat=chat,
)
identifier_values = self._normalize_identifier_candidates(sender, chat)
if not identifier_values:
@@ -853,6 +904,11 @@ class WhatsAppClient(ClientBase):
or int(time.time() * 1000)
)
receipt_type = str(self._pluck(event, "Type") or "").strip()
self._remember_contact(
sender or chat,
jid=sender,
chat=chat,
)
for candidate in self._normalize_identifier_candidates(sender, chat):
await self.ur.message_read(
@@ -885,6 +941,11 @@ class WhatsAppClient(ClientBase):
state = self._pluck(event, "State") or self._pluck(event, "state")
state_text = str(state or "").strip().lower()
is_typing = state_text in {"1", "composing", "chat_presence_composing"}
self._remember_contact(
sender or chat,
jid=sender,
chat=chat,
)
for candidate in self._normalize_identifier_candidates(sender, chat):
if is_typing:
@@ -908,6 +969,7 @@ class WhatsAppClient(ClientBase):
is_unavailable = bool(
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
)
self._remember_contact(sender, jid=sender)
for candidate in self._normalize_identifier_candidates(sender):
if is_unavailable:

View File

@@ -0,0 +1,129 @@
{% extends "index.html" %}
{% block content %}
<section class="section">
<div class="container">
<div class="level" style="margin-bottom: 0.75rem;">
<div class="level-left">
<div>
<h1 class="title is-4" style="margin-bottom: 0.2rem;">Contact Match</h1>
<p class="is-size-7 has-text-grey">
Manually link Signal, WhatsApp, Instagram, and XMPP identifiers to people.
</p>
</div>
</div>
<div class="level-right">
<a class="button is-light" href="{% url 'compose_workspace' %}">
<span class="icon is-small"><i class="fa-solid fa-table-cells-large"></i></span>
<span>Manual Workspace</span>
</a>
</div>
</div>
{% if notice_message %}
<article class="notification is-{{ notice_level|default:'info' }} is-light">
{{ notice_message }}
</article>
{% endif %}
<div class="columns is-variable is-4">
<div class="column is-5">
<article class="box">
<h2 class="title is-6">Create Or Link Identifier</h2>
<form method="post">
{% csrf_token %}
<div class="field">
<label class="label is-small">Service</label>
<div class="select is-fullwidth">
<select name="service" required>
{% for key, label in service_choices %}
<option value="{{ key }}" {% if key == prefill_service %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Identifier</label>
<div class="control">
<input class="input" type="text" name="identifier" value="{{ prefill_identifier }}" required>
</div>
</div>
<div class="field">
<label class="label is-small">Existing Person</label>
<div class="select is-fullwidth">
<select name="person_id">
<option value="">- Select person -</option>
{% for person in people %}
<option value="{{ person.id }}">{{ person.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Or Create Person</label>
<div class="control">
<input class="input" type="text" name="person_name" placeholder="New person name">
</div>
</div>
<button class="button is-link" type="submit">
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
<span>Save Match</span>
</button>
</form>
</article>
</div>
<div class="column is-7">
<article class="box">
<h2 class="title is-6">Discovered Contacts</h2>
{% if candidates %}
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Contact</th>
<th>Service</th>
<th>Identifier</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in candidates %}
<tr>
<td>{{ row.person_name }}</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 row.linked_person %}
<span class="tag is-success is-light">linked</span>
{% else %}
<span class="tag is-warning is-light">unlinked</span>
{% endif %}
</td>
<td>
<a class="button is-small is-light" href="{{ row.compose_url }}">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span>Message</span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="has-text-grey">No contacts discovered yet.</p>
{% endif %}
</article>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -64,7 +64,7 @@
<span
class="tag is-dark"
style="min-width: 2.5rem; justify-content: center;">
<i class="{{ manual_icon_class }}" aria-hidden="true"></i>
<i class="{{ row.service_icon_class|default:manual_icon_class }}" aria-hidden="true"></i>
</span>
<span
class="tag is-white"
@@ -103,6 +103,15 @@
</span>
</span>
</button>
{% if not row.linked_person %}
<a
class="button is-small is-light"
href="{{ row.match_url }}"
title="Link this identifier to a person">
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
<span>Match</span>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}

View File

@@ -1,15 +1,23 @@
{% if items %}
{% for item in items %}
<a class="navbar-item" href="{{ item.compose_url }}">
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
<span class="icon is-small"><i class="{{ item.service_icon_class|default:manual_icon_class }}"></i></span>
<span style="margin-left: 0.35rem;">
{{ item.person_name }} · {{ item.service|title }}
{% if not item.linked_person %}
<small class="has-text-grey"> · unlinked</small>
{% endif %}
</span>
</a>
{% endfor %}
{% else %}
<a class="navbar-item is-disabled">No contacts found.</a>
{% endif %}
<hr class="navbar-divider" style="margin: 0.2rem 0;">
<a class="navbar-item" href="{{ match_url }}">
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
<span style="margin-left: 0.35rem;">Match Contacts</span>
</a>
{% if is_preview %}
<hr class="navbar-divider" style="margin: 0.2rem 0;">
<a

View File

@@ -38,7 +38,7 @@
</button>
{% if show_contact_actions %}
{% if type == 'page' %}
<a href="{% url 'signal_contacts' type=type pk=item %}"><button
<a href="{% url contacts_url_name type=type pk=item %}"><button
class="button">
<span class="icon-text">
<span class="icon">
@@ -47,7 +47,7 @@
</span>
</button>
</a>
<a href="{% url 'signal_chats' type=type pk=item %}"><button
<a href="{% url chats_url_name type=type pk=item %}"><button
class="button">
<span class="icon-text">
<span class="icon">
@@ -59,7 +59,7 @@
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'signal_contacts' type=type pk=item %}"
hx-get="{% url contacts_url_name type=type pk=item %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
@@ -72,7 +72,7 @@
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'signal_chats' type=type pk=item %}"
hx-get="{% url chats_url_name type=type pk=item %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"

View File

@@ -68,6 +68,16 @@
</span>
</button>
{% endif %}
<a href="{{ item.match_url }}"><button
class="button"
title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</a>
<a href="{{ item.ai_url }}"><button
class="button"
title="Open AI workspace">
@@ -102,6 +112,13 @@
</span>
</button>
{% endif %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button></a>
<a href="{{ item.ai_url }}"><button class="button">
<span class="icon-text">
<span class="icon">

View File

@@ -0,0 +1,57 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>chat</th>
<th>identifier</th>
<th>person</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{{ item.name|default:"WhatsApp Chat" }}</td>
<td>
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.identifier }}');">
<span class="icon" data-tooltip="Copy identifier">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
<div class="buttons">
{% if type == 'page' %}
<a href="{{ item.compose_page_url }}" class="button" title="Manual text mode">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Manual text mode widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="has-text-grey">No WhatsApp chats discovered yet.</td>
</tr>
{% endfor %}
</table>

View File

@@ -0,0 +1,53 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>name</th>
<th>identifier</th>
<th>jid</th>
<th>person</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{{ item.name|default:"-" }}</td>
<td>
<code>{{ item.identifier }}</code>
</td>
<td>{{ item.jid|default:"-" }}</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
<div class="buttons">
{% if type == 'page' %}
<a href="{{ item.compose_page_url }}" class="button" title="Open manual chat">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button"
title="Open manual chat widget">
<span class="icon"><i class="fa-solid fa-paper-plane"></i></span>
</button>
{% endif %}
<a href="{{ item.match_url }}" class="button" title="Match identifier">
<span class="icon"><i class="fa-solid fa-link"></i></span>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="has-text-grey">No WhatsApp contacts discovered yet.</td>
</tr>
{% endfor %}
</table>

View File

@@ -29,6 +29,7 @@ from core.messaging import media_bridge
from core.messaging.utils import messages_to_string
from core.models import (
AI,
Chat,
ChatSession,
Message,
MessageEvent,
@@ -1334,6 +1335,120 @@ def _compose_urls(service, identifier, person_id):
}
def _service_icon_class(service: str) -> str:
key = str(service or "").strip().lower()
if key == "signal":
return "fa-solid fa-signal"
if key == "whatsapp":
return "fa-brands fa-whatsapp"
if key == "instagram":
return "fa-brands fa-instagram"
if key == "xmpp":
return "fa-solid fa-comments"
return "fa-solid fa-address-card"
def _manual_contact_rows(user):
rows = []
seen = set()
identifiers = (
PersonIdentifier.objects.filter(user=user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
def add_row(*, service, identifier, person=None, source="linked", account=""):
service_key = _default_service(service)
identifier_value = str(identifier or "").strip()
if not identifier_value:
return
key = (service_key, identifier_value)
if key in seen:
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)
rows.append(
{
"person_name": person_name,
"service": service_key,
"service_icon_class": _service_icon_class(service_key),
"identifier": identifier_value,
"compose_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"linked_person": bool(person),
"source": source,
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
),
}
)
for row in identifiers:
add_row(
service=row.service,
identifier=row.identifier,
person=row.person,
source="linked",
)
signal_links = {
str(row.identifier): row
for row in (
PersonIdentifier.objects.filter(user=user, service="signal")
.select_related("person")
.order_by("id")
)
}
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(),
):
if not candidate:
continue
linked = signal_links.get(candidate)
add_row(
service="signal",
identifier=candidate,
person=(linked.person if linked else None),
source="signal_chat",
account=str(chat.account or ""),
)
whatsapp_links = {
str(row.identifier): row
for row in (
PersonIdentifier.objects.filter(user=user, service="whatsapp")
.select_related("person")
.order_by("id")
)
}
wa_contacts = transport.get_runtime_state("whatsapp").get("contacts") or []
if isinstance(wa_contacts, list):
for item in wa_contacts:
if not isinstance(item, dict):
continue
candidate = str(item.get("identifier") or item.get("jid") or "").strip()
if not candidate:
continue
linked = whatsapp_links.get(candidate)
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 ""),
)
rows.sort(key=lambda row: (row["person_name"].lower(), row["service"], row["identifier"]))
return rows
def _load_messages(user, person_identifier, limit):
if person_identifier is None:
return {"session": None, "messages": []}
@@ -1450,32 +1565,18 @@ class ComposeContactsDropdown(LoginRequiredMixin, View):
def get(self, request):
all_value = str(request.GET.get("all") or "").strip().lower()
fetch_all = all_value in {"1", "true", "yes", "y", "all"}
preview_limit = 5
queryset = (
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
rows = list(queryset) if fetch_all else list(queryset[:preview_limit])
items = []
for row in rows:
urls = _compose_urls(row.service, row.identifier, row.person_id)
items.append(
{
"person_name": row.person.name,
"service": row.service,
"identifier": row.identifier,
"compose_url": urls["page_url"],
}
)
preview_limit = 10
contact_rows = _manual_contact_rows(request.user)
rows = contact_rows if fetch_all else contact_rows[:preview_limit]
return render(
request,
"partials/nav-contacts-dropdown.html",
{
"items": items,
"items": rows,
"manual_icon_class": "fa-solid fa-paper-plane",
"is_preview": not fetch_all,
"fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
"match_url": reverse("compose_contact_match"),
},
)
@@ -1517,33 +1618,15 @@ class ComposeWorkspace(LoginRequiredMixin, View):
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
def _contact_rows(self, user):
rows = []
queryset = (
PersonIdentifier.objects.filter(user=user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
for row in queryset:
urls = _compose_urls(row.service, row.identifier, row.person_id)
rows.append(
{
"person_name": row.person.name,
"service": row.service,
"identifier": row.identifier,
"compose_widget_url": urls["widget_url"],
}
)
return rows
def get(self, request):
limit = _safe_limit(request.GET.get("limit") or 40)
contact_rows = _manual_contact_rows(request.user)
context = {
"title": "Manual Workspace",
"unique": "compose-workspace-contacts",
"window_content": "partials/compose-workspace-contacts-widget.html",
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": self._contact_rows(request.user),
"contact_rows": contact_rows,
"limit": limit,
"limit_options": [20, 40, 60, 100, 200],
"manual_icon_class": "fa-solid fa-paper-plane",
@@ -1551,6 +1634,87 @@ class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
return render(request, "mixins/wm/widget.html", context)
class ComposeContactMatch(LoginRequiredMixin, View):
template_name = "pages/compose-contact-match.html"
def _service_choices(self):
return [
("signal", "Signal"),
("whatsapp", "WhatsApp"),
("instagram", "Instagram"),
("xmpp", "XMPP"),
]
def _context(self, request, notice="", level="info"):
people = (
Person.objects.filter(user=request.user)
.prefetch_related("personidentifier_set")
.order_by("name")
)
candidates = _manual_contact_rows(request.user)
return {
"people": people,
"candidates": candidates,
"service_choices": self._service_choices(),
"notice_message": notice,
"notice_level": level,
"prefill_service": _default_service(request.GET.get("service")),
"prefill_identifier": str(request.GET.get("identifier") or "").strip(),
}
def get(self, request):
return render(request, self.template_name, self._context(request))
def post(self, request):
person_id = str(request.POST.get("person_id") or "").strip()
person_name = str(request.POST.get("person_name") or "").strip()
service = _default_service(request.POST.get("service"))
identifier = str(request.POST.get("identifier") or "").strip()
if not identifier:
return render(
request,
self.template_name,
self._context(request, "Identifier is required.", "warning"),
)
person = None
if person_id:
person = Person.objects.filter(id=person_id, user=request.user).first()
if person is None and person_name:
person = Person.objects.create(user=request.user, name=person_name)
if person is None:
return render(
request,
self.template_name,
self._context(request, "Select a person or create one.", "warning"),
)
row = PersonIdentifier.objects.filter(
user=request.user,
service=service,
identifier=identifier,
).first()
if row is None:
PersonIdentifier.objects.create(
user=request.user,
person=person,
service=service,
identifier=identifier,
)
message = f"Linked {identifier} ({service}) to {person.name}."
else:
if row.person_id != person.id:
row.person = person
row.save(update_fields=["person"])
message = f"Re-linked {identifier} ({service}) to {person.name}."
else:
message = f"{identifier} ({service}) is already linked to {person.name}."
return render(
request,
self.template_name,
self._context(request, message, "success"),
)
class ComposePage(LoginRequiredMixin, View):
template_name = "pages/compose.html"

View File

@@ -3,6 +3,7 @@ import requests
from django.conf import settings
from django.shortcuts import render
from django.urls import reverse
from urllib.parse import urlencode
from django.views import View
from mixins.views import ObjectList, ObjectRead
@@ -67,6 +68,8 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
"service_label": label,
"account_add_url_name": add_url_name,
"show_contact_actions": show_contact_actions,
"contacts_url_name": f"{service}_contacts",
"chats_url_name": f"{service}_chats",
"endpoint_base": str(
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
).rstrip("/")
@@ -187,6 +190,12 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
),
"manual_icon_class": "fa-solid fa-paper-plane",
"can_compose": bool(compose_page_url),
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'signal', 'identifier': identifier_value})}"
if identifier_value
else reverse("compose_contact_match")
),
}
)
return rows

View File

@@ -1,9 +1,12 @@
from django.shortcuts import render
from django.urls import reverse
from urllib.parse import urlencode
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import PersonIdentifier
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
import time
@@ -56,12 +59,107 @@ class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
"service": "whatsapp",
"service_label": "WhatsApp",
"account_add_url_name": "whatsapp_account_add",
"show_contact_actions": False,
"show_contact_actions": True,
"contacts_url_name": "whatsapp_contacts",
"chats_url_name": "whatsapp_chats",
"service_warning": transport.get_service_warning("whatsapp"),
}
return self._normalize_accounts(transport.list_accounts("whatsapp"))
class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
list_template = "partials/whatsapp-contacts-list.html"
context_object_name_singular = "WhatsApp Contact"
context_object_name = "WhatsApp Contacts"
list_url_name = "whatsapp_contacts"
list_url_args = ["type", "pk"]
def get_queryset(self, *args, **kwargs):
state = transport.get_runtime_state("whatsapp")
runtime_contacts = state.get("contacts") or []
rows = []
seen = set()
for item in runtime_contacts:
if not isinstance(item, dict):
continue
identifier = str(
item.get("identifier") or item.get("jid") or item.get("chat") or ""
).strip()
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()
)
urls = _compose_urls(
"whatsapp",
identifier,
linked.person_id if linked else None,
)
rows.append(
{
"identifier": identifier,
"jid": str(item.get("jid") or ""),
"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 "",
"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})}"
),
}
)
# Include already-linked WhatsApp contacts not yet discovered by runtime.
linked_rows = (
PersonIdentifier.objects.filter(
user=self.request.user,
service="whatsapp",
)
.select_related("person")
.order_by("person__name", "identifier")
)
for row in linked_rows:
identifier = str(row.identifier or "").strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
urls = _compose_urls("whatsapp", identifier, row.person_id)
rows.append(
{
"identifier": identifier,
"jid": "",
"name": row.person.name,
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": row.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})}"
),
}
)
return rows
class WhatsAppChatsList(WhatsAppContactsList):
list_template = "partials/whatsapp-chats-list.html"
context_object_name_singular = "WhatsApp Chat"
context_object_name = "WhatsApp Chats"
list_url_name = "whatsapp_chats"
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_template = "partials/whatsapp-account-add.html"
service = "whatsapp"