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"