Implement contact matching

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

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"