Pull groups from WhatsApp

This commit is contained in:
2026-02-18 21:22:45 +00:00
parent 521692c458
commit c400c46e7d
12 changed files with 643 additions and 136 deletions

View File

@@ -7,7 +7,7 @@ from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import ChatSession, Message, PersonIdentifier
from core.models import PersonIdentifier
from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -263,84 +263,102 @@ class WhatsAppChatsList(WhatsAppContactsList):
rows = []
seen = set()
state = transport.get_runtime_state("whatsapp")
runtime_contacts = state.get("contacts") or []
runtime_name_map = {}
for item in runtime_contacts:
if not isinstance(item, dict):
continue
identifier = str(item.get("identifier") or "").strip()
runtime_groups = state.get("groups") or []
combined_contacts = []
for item in runtime_contacts + runtime_groups:
if isinstance(item, dict):
combined_contacts.append(item)
contact_index = {}
for item in combined_contacts:
raw_identifier = str(
item.get("identifier") or item.get("jid") or item.get("chat") or ""
).strip()
jid = str(item.get("jid") or "").strip()
name = str(item.get("name") or item.get("chat") or "").strip()
base_id = raw_identifier.split("@", 1)[0].strip()
jid_base = jid.split("@", 1)[0].strip()
for key in {raw_identifier, base_id, jid, jid_base}:
if key:
contact_index[key] = {"name": name, "jid": jid}
history_anchors = state.get("history_anchors") or {}
for key, anchor in (history_anchors.items() if isinstance(history_anchors, dict) else []):
identifier = str(key or "").strip()
if not identifier:
continue
runtime_name_map[identifier] = str(item.get("name") or "").strip()
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 or identifier in seen:
identifier = identifier.split("@", 1)[0].strip() or identifier
if identifier in seen:
continue
seen.add(identifier)
latest = (
Message.objects.filter(user=self.request.user, session=session)
.order_by("-ts")
.first()
anchor_jid = str((anchor or {}).get("chat_jid") or "").strip()
contact = contact_index.get(identifier) or contact_index.get(anchor_jid)
jid = (contact or {}).get("jid") or anchor_jid or identifier
linked = self._linked_identifier(identifier, jid)
urls = _compose_urls(
"whatsapp",
identifier,
linked.person_id if linked else None,
)
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]}..."
display_name = (
preview
or runtime_name_map.get(identifier)
or session.identifier.person.name
name = (
(contact or {}).get("name")
or (linked.person.name if linked else "")
or jid
or identifier
or "WhatsApp Chat"
)
rows.append(
{
"identifier": identifier,
"jid": identifier,
"name": display_name,
"jid": jid,
"name": name,
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": session.identifier.person.name,
"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})}"
),
"last_ts": int(latest.ts or 0) if latest else 0,
"last_ts": int((anchor or {}).get("ts") or (anchor or {}).get("updated_at") or 0),
}
)
# Fallback: show synced WhatsApp contacts as chat entries even when no
# local message history exists yet.
for item in runtime_contacts:
if not isinstance(item, dict):
continue
identifier = str(item.get("identifier") or item.get("jid") or "").strip()
if rows:
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
return rows
# Fallback: if no anchors yet, surface the runtime contacts (best effort live state)
for item in combined_contacts:
identifier = str(
item.get("identifier") or item.get("jid") or item.get("chat") or ""
).strip()
if not identifier:
continue
identifier = identifier.split("@", 1)[0].strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
linked = self._linked_identifier(identifier, str(item.get("jid") or ""))
jid = str(item.get("jid") or "").strip()
linked = self._linked_identifier(identifier, jid)
urls = _compose_urls(
"whatsapp",
identifier,
linked.person_id if linked else None,
)
name = (
str(item.get("name") or item.get("chat") or "").strip()
or (linked.person.name if linked else "")
or jid
or identifier
or "WhatsApp Chat"
)
rows.append(
{
"identifier": identifier,
"jid": str(item.get("jid") or identifier).strip(),
"name": str(item.get("name") or "WhatsApp Chat").strip()
or "WhatsApp Chat",
"jid": jid or identifier,
"name": name,
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
@@ -352,10 +370,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
"last_ts": 0,
}
)
if rows:
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
return rows
return super().get_queryset(*args, **kwargs)
return rows
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):

View File

@@ -3497,12 +3497,6 @@ def _workspace_nav_urls(person):
}
def _person_plan_or_404(request, person_id, plan_id):
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
return person, plan
class AIWorkspace(LoginRequiredMixin, View):
template_name = "pages/ai-workspace.html"
@@ -4437,7 +4431,12 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(
PatternMitigationPlan,
id=plan_id,
user=request.user,
)
text = (request.POST.get("message") or "").strip()
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="ask_ai"
@@ -4519,11 +4518,14 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View):
text=assistant_text,
)
return _render_mitigation_panel(
return render(
request,
person,
plan,
active_tab=active_tab,
"partials/ai-workspace-mitigation-panel.html",
_mitigation_panel_context(
person=person,
plan=plan,
active_tab=active_tab,
),
)
@@ -4534,7 +4536,12 @@ class AIWorkspaceExportArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(
PatternMitigationPlan,
id=plan_id,
user=request.user,
)
artifact_type = (request.POST.get("artifact_type") or "rulebook").strip()
if artifact_type not in {"rulebook", "rules", "games", "corrections"}:
@@ -4581,7 +4588,8 @@ class AIWorkspaceCreateArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4635,7 +4643,8 @@ class AIWorkspaceUpdateArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4710,7 +4719,8 @@ class AIWorkspaceDeleteArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4749,7 +4759,8 @@ class AIWorkspaceDeleteArtifactList(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4786,7 +4797,8 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
source_ref = (request.POST.get("source_ref") or "").strip()
share_target = (request.POST.get("share_target") or "self").strip()
@@ -5051,7 +5063,8 @@ class AIWorkspaceAutoSettings(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
auto_settings = _get_or_create_auto_settings(request.user, plan.conversation)
auto_settings.enabled = _is_truthy(request.POST.get("enabled"))
@@ -5121,7 +5134,8 @@ class AIWorkspaceUpdateFundamentals(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
fundamentals_text = request.POST.get("fundamentals_text") or ""
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="fundamentals"
@@ -5145,7 +5159,8 @@ class AIWorkspaceUpdatePlanMeta(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="plan_board"
)