Implement contact matching
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user